From 0e05b2bb6b4c6c465a2a83902d642fcf28e2f9e7 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 11 Sep 2019 11:52:18 +0200 Subject: [PATCH 001/499] use name length limit in header and project settings --- jsapp/js/constants.es6 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index da853c73cf..76dd059136 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -137,6 +137,18 @@ export const ASSET_TYPES = { id: 'survey', label: t('project') } +} + +export default { + AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, + update_states: update_states, + VALIDATION_STATUSES: VALIDATION_STATUSES, + VALIDATION_STATUSES_LIST: VALIDATION_STATUSES_LIST, + PROJECT_SETTINGS_CONTEXTS: PROJECT_SETTINGS_CONTEXTS, + MODAL_TYPES: MODAL_TYPES, + ASSET_TYPES: ASSET_TYPES, + HOOK_LOG_STATUSES: HOOK_LOG_STATUSES, + NAME_MAX_LENGTH: 255 }; export const ASSET_KINDS = new Map(); From f7490cf5816ced6911008cb0789ddce096aa0515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 15 Feb 2019 14:25:31 -0500 Subject: [PATCH 002/499] Use Redis as session storage --- dependencies/pip/dev_requirements.txt | 108 +++++++++++++++++++++++++ dependencies/pip/external_services.txt | 74 +++++++++++++++++ dependencies/pip/requirements.in | 3 + dependencies/pip/requirements.txt | 72 +++++++++++++++++ kobo/settings/base.py | 14 ++-- kpi/utils/redis_helper.py | 51 +++++------- 6 files changed, 287 insertions(+), 35 deletions(-) diff --git a/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index 5f24001ead..180b65d5c0 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -24,6 +24,7 @@ celery[redis]==4.3.0 # via -r dependencies/pip/requirements.in certifi==2019.9.11 # via requests cffi==1.13.2 # via bcrypt, cryptography, pynacl chardet==3.0.4 # via requests +<<<<<<< HEAD cryptography==2.8 # via paramiko, pyopenssl cssselect==1.1.0 # via pyquery decorator==4.4.1 # via ipython, traitlets @@ -67,6 +68,56 @@ geojson-rewind==0.2.0 # via -r dependencies/pip/requirements.in, formpack idna==2.8 # via requests importlib-metadata==0.23 # via jsonschema, kombu, path.py invoke==1.3.0 # via fabric +======= +configparser==3.7.1 # via importlib-metadata +contextlib2==0.5.5 # via importlib-metadata +cookies==2.2.1 # via responses +cryptography==2.2.2 # via fabric, paramiko, pyopenssl +cssselect==1.0.3 # via pyquery +cyordereddict==1.0.0 +decorator==4.3.2 # via ipython, traitlets +defusedxml==0.5.0 # via djangorestframework-xml +dj-database-url==0.4.1 +dj-static==0.0.6 +django-braces==1.13.0 +django-celery-beat==1.1.1 +django-constance[database]==2.2.0 +django-debug-toolbar==1.4 +django-extensions==1.6.7 +django-haystack==2.6.0 +django-jsonbfield==0.1.0 +django-loginas==0.2.3 +django-markitup==3.0.0 +django-mptt==0.8.7 +django-oauth-toolkit==0.10.0 +django-picklefield==1.0.0 # via django-constance +django-private-storage==2.1.2 +django-redis-sessions==0.6.1 +django-registration-redux==1.3 +django-reversion==2.0.8 +django-ses==0.8.9 +django-storages==1.6.5 +django-taggit==0.22.0 +django-toolbelt==0.0.1 +django-webpack-loader==0.3.0 +django==1.8.19 +djangorestframework-xml==1.4.0 +djangorestframework==3.6.4 +docutils==0.14 # via botocore, statistics +drf-extensions==0.3.1 +enum34==1.1.6 # via cryptography, traitlets +fabric==2.4.0 +formencode==1.3.1 # via pyxform +funcsigs==1.0.2 # via begins, mock +functools32==3.2.3.post2 # via jsonschema +future==0.17.1 # via backports.os, django-ses +futures==3.2.0 # via s3transfer +gunicorn==19.4.5 +idna==2.8 # via cryptography, requests +importlib-metadata==0.8 # via path.py +invoke==1.2.0 # via fabric +ipaddress==1.0.17 # via cryptography +>>>>>>> Use Redis as session storage ipython-genutils==0.2.0 # via traitlets ipython==7.9.0 # via -r dependencies/pip/dev_requirements.in jedi==0.15.1 # via ipython @@ -75,6 +126,7 @@ jsonfield==2.0.2 # via -r dependencies/pip/requirements.in jsonschema==3.1.1 # via formpack kombu==4.6.6 # via -r dependencies/pip/requirements.in, celery linecache2==1.0.0 # via traceback2 +<<<<<<< HEAD lxml==4.4.1 # via -r dependencies/pip/requirements.in, formpack, pyquery markdown==3.1.1 # via -r dependencies/pip/requirements.in, django-markdownx mock==3.0.5 # via -r dependencies/pip/dev_requirements.in @@ -86,12 +138,25 @@ paramiko==2.6.0 # via fabric parso==0.5.1 # via jedi path.py==12.0.2 # via formpack pexpect==4.7.0 # via ipython +======= +lxml==4.3.0 +markdown==3.0.1 +mock==2.0.0 +ndg-httpsclient==0.4.2 +oauthlib==1.0.3 +paramiko==2.4.2 # via fabric +path.py==11.5.0 +pathlib2==2.3.3 # via importlib-metadata, ipython, pickleshare +pbr==4.0.2 # via mock +pexpect==4.6.0 # via ipython +>>>>>>> Use Redis as session storage pickleshare==0.7.5 # via ipython pillow==6.2.1 # via django-markdownx pluggy==0.13.0 # via pytest prompt-toolkit==2.0.10 # via ipython psycopg2==2.8.4 # via -r dependencies/pip/requirements.in ptyprocess==0.6.0 # via pexpect +<<<<<<< HEAD py==1.8.0 # via pytest pyasn1==0.4.7 # via -r dependencies/pip/requirements.in, ndg-httpsclient pycparser==2.19 # via cffi @@ -119,10 +184,40 @@ sqlparse==0.3.0 # via -r dependencies/pip/requirements.in, django, dja static3==0.7.0 # via -r dependencies/pip/requirements.in, dj-static statistics==1.0.3.5 # via formpack tabulate==0.8.5 # via -r dependencies/pip/requirements.in +======= +py==1.4.31 # via pytest +pyasn1==0.1.9 +pycparser==2.14 # via cffi +pygments==2.1.3 +pymongo==3.7.2 +pynacl==1.3.0 # via paramiko +pyopenssl==18.0.0 +pyquery==1.4.0 +pytest-django==3.1.2 +pytest-env==0.6.2 +pytest==3.0.3 # via pytest-django, pytest-env +python-dateutil==2.7.5 +python-digest==1.7 +pytz==2018.9 +pyxform==0.12.0 +redis==3.1.0 # via django-redis-sessions +requests==2.21.0 +responses==0.9.0 +s3transfer==0.1.13 # via boto3 +scandir==1.9.0 # via pathlib2 +shortuuid==0.4.3 +simplegeneric==0.8.1 # via ipython +six==1.12.0 +sqlparse==0.1.19 +static3==0.7.0 +statistics==1.0.3.5 +tabulate==0.8.2 +>>>>>>> Use Redis as session storage traceback2==1.4.0 # via unittest2 traitlets==4.3.3 # via ipython unicodecsv==0.14.1 # via -r dependencies/pip/requirements.in, pyxform unittest2==1.1.0 # via pyxform +<<<<<<< HEAD urllib3==1.25.7 # via botocore, requests uwsgi==2.0.18 # via -r dependencies/pip/requirements.in vine==1.3.0 # via amqp, celery @@ -136,3 +231,16 @@ zipp==0.6.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip # setuptools +======= +urllib3==1.24.1 # via botocore, requests +uwsgi==2.0.17 +vine==1.2.0 # via amqp +wcwidth==0.1.7 # via prompt-toolkit +werkzeug==0.14.1 +whitenoise==3.3.1 +whoosh==2.7.4 +xlrd==1.1.0 +xlsxwriter==1.1.2 +xlwt==1.3.0 +zipp==0.3.3 # via importlib-metadata +>>>>>>> Use Redis as session storage diff --git a/dependencies/pip/external_services.txt b/dependencies/pip/external_services.txt index cc467066b1..f16207ea19 100644 --- a/dependencies/pip/external_services.txt +++ b/dependencies/pip/external_services.txt @@ -20,6 +20,7 @@ celery[redis]==4.3.0 # via -r dependencies/pip/requirements.in certifi==2019.9.11 # via requests cffi==1.13.2 # via cryptography chardet==3.0.4 # via requests +<<<<<<< HEAD cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery defusedxml==0.6.0 # via djangorestframework-xml @@ -55,6 +56,44 @@ djangorestframework-xml==1.4.0 # via -r dependencies/pip/requirements.in djangorestframework==3.10.3 # via -r dependencies/pip/requirements.in, drf-extensions docutils==0.15.2 # via botocore, statistics drf-extensions==0.5.0 # via -r dependencies/pip/requirements.in +======= +configparser==3.7.1 # via importlib-metadata +contextlib2==0.5.5 # via importlib-metadata, raven +cookies==2.2.1 # via responses +cryptography==2.2.2 # via pyopenssl +cssselect==1.0.3 # via pyquery +cyordereddict==1.0.0 +defusedxml==0.5.0 # via djangorestframework-xml +dj-database-url==0.4.1 +dj-static==0.0.6 +django-braces==1.13.0 +django-celery-beat==1.1.1 +django-constance[database]==2.2.0 +django-debug-toolbar==1.4 +django-extensions==1.6.7 +django-haystack==2.6.0 +django-jsonbfield==0.1.0 +django-loginas==0.2.3 +django-markitup==3.0.0 +django-mptt==0.8.7 +django-oauth-toolkit==0.10.0 +django-picklefield==1.0.0 # via django-constance +django-private-storage==2.1.2 +django-redis-sessions==0.6.1 +django-registration-redux==1.3 +django-reversion==2.0.8 +django-ses==0.8.9 +django-storages==1.6.5 +django-taggit==0.22.0 +django-toolbelt==0.0.1 +django-webpack-loader==0.3.0 +django==1.8.19 +djangorestframework-xml==1.4.0 +djangorestframework==3.6.4 +docutils==0.14 # via botocore, statistics +drf-extensions==0.3.1 +enum34==1.1.6 # via cryptography +>>>>>>> Use Redis as session storage formencode==1.3.1 # via pyxform future==0.18.2 # via -r dependencies/pip/requirements.in geojson-rewind==0.2.0 # via -r dependencies/pip/requirements.in, formpack @@ -65,6 +104,7 @@ jsonfield==2.0.2 # via -r dependencies/pip/requirements.in jsonschema==3.1.1 # via formpack kombu==4.6.6 # via -r dependencies/pip/requirements.in, celery linecache2==1.0.0 # via traceback2 +<<<<<<< HEAD lxml==4.4.1 # via -r dependencies/pip/requirements.in, formpack, pyquery markdown==3.1.1 # via -r dependencies/pip/requirements.in, django-markdownx more-itertools==7.2.0 # via zipp @@ -95,6 +135,40 @@ sqlparse==0.3.0 # via -r dependencies/pip/requirements.in, django, dja static3==0.7.0 # via -r dependencies/pip/requirements.in, dj-static statistics==1.0.3.5 # via formpack tabulate==0.8.5 # via -r dependencies/pip/requirements.in +======= +lxml==4.3.0 +markdown==3.0.1 +mock==2.0.0 +ndg-httpsclient==0.4.2 +newrelic==2.84.0.64 +oauthlib==1.0.3 +path.py==11.5.0 +pathlib2==2.3.3 # via importlib-metadata +pbr==4.0.2 # via mock +psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt +pyasn1==0.1.9 +pycparser==2.14 # via cffi +pygments==2.1.3 +pymongo==3.7.2 +pyopenssl==18.0.0 +pyquery==1.4.0 +python-dateutil==2.7.5 +python-digest==1.7 +pytz==2018.9 +pyxform==0.12.0 +raven==5.32.0 +redis==3.1.0 # via django-redis-sessions +requests==2.21.0 +responses==0.9.0 +s3transfer==0.1.13 # via boto3 +scandir==1.9.0 # via pathlib2 +shortuuid==0.4.3 +six==1.12.0 +sqlparse==0.1.19 +static3==0.7.0 +statistics==1.0.3.5 +tabulate==0.8.2 +>>>>>>> Use Redis as session storage traceback2==1.4.0 # via unittest2 transifex-client==0.12.5 # via -r dependencies/pip/external_services.in unicodecsv==0.14.1 # via -r dependencies/pip/requirements.in, pyxform diff --git a/dependencies/pip/requirements.in b/dependencies/pip/requirements.in index 393e1f00e2..c330c708f1 100644 --- a/dependencies/pip/requirements.in +++ b/dependencies/pip/requirements.in @@ -45,7 +45,10 @@ django-private-storage djangorestframework djangorestframework-xml django-redis-sessions +<<<<<<< HEAD django-request-cache +======= +>>>>>>> Use Redis as session storage drf-extensions future geojson-rewind diff --git a/dependencies/pip/requirements.txt b/dependencies/pip/requirements.txt index c22b1fbd08..10badf44ce 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -21,6 +21,7 @@ celery[redis]==4.3.0 # via -r dependencies/pip/requirements.in certifi==2019.9.11 # via requests cffi==1.13.2 # via cryptography chardet==3.0.4 # via requests +<<<<<<< HEAD cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery defusedxml==0.6.0 # via djangorestframework-xml @@ -56,6 +57,44 @@ djangorestframework-xml==1.4.0 # via -r dependencies/pip/requirements.in djangorestframework==3.10.3 # via -r dependencies/pip/requirements.in, drf-extensions docutils==0.15.2 # via botocore, statistics drf-extensions==0.5.0 # via -r dependencies/pip/requirements.in +======= +configparser==3.7.1 # via importlib-metadata +contextlib2==0.5.5 # via importlib-metadata +cookies==2.2.1 # via responses +cryptography==2.2.2 # via pyopenssl +cssselect==1.0.3 # via pyquery +cyordereddict==1.0.0 +defusedxml==0.5.0 # via djangorestframework-xml +dj-database-url==0.4.1 +dj-static==0.0.6 +django-braces==1.13.0 +django-celery-beat==1.1.1 +django-constance[database]==2.2.0 +django-debug-toolbar==1.4 +django-extensions==1.6.7 +django-haystack==2.6.0 +django-jsonbfield==0.1.0 +django-loginas==0.2.3 +django-markitup==3.0.0 +django-mptt==0.8.7 +django-oauth-toolkit==0.10.0 +django-picklefield==1.0.0 # via django-constance +django-private-storage==2.1.2 +django-redis-sessions==0.6.1 +django-registration-redux==1.3 +django-reversion==2.0.8 +django-ses==0.8.9 +django-storages==1.6.5 +django-taggit==0.22.0 +django-toolbelt==0.0.1 +django-webpack-loader==0.3.0 +django==1.8.19 +djangorestframework-xml==1.4.0 +djangorestframework==3.6.4 +docutils==0.14 # via botocore, statistics +drf-extensions==0.3.1 +enum34==1.1.6 # via cryptography +>>>>>>> Use Redis as session storage formencode==1.3.1 # via pyxform future==0.18.2 # via -r dependencies/pip/requirements.in geojson-rewind==0.2.0 # via -r dependencies/pip/requirements.in, formpack @@ -66,6 +105,7 @@ jsonfield==2.0.2 # via -r dependencies/pip/requirements.in jsonschema==3.1.1 # via formpack kombu==4.6.6 # via -r dependencies/pip/requirements.in, celery linecache2==1.0.0 # via traceback2 +<<<<<<< HEAD lxml==4.4.1 # via -r dependencies/pip/requirements.in, formpack, pyquery markdown==3.1.1 # via -r dependencies/pip/requirements.in, django-markdownx more-itertools==7.2.0 # via zipp @@ -95,6 +135,38 @@ sqlparse==0.3.0 # via -r dependencies/pip/requirements.in, django, dja static3==0.7.0 # via -r dependencies/pip/requirements.in, dj-static statistics==1.0.3.5 # via formpack tabulate==0.8.5 # via -r dependencies/pip/requirements.in +======= +lxml==4.3.0 +markdown==3.0.1 +mock==2.0.0 +ndg-httpsclient==0.4.2 +oauthlib==1.0.3 +path.py==11.5.0 +pathlib2==2.3.3 # via importlib-metadata +pbr==4.0.2 # via mock +psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt +pyasn1==0.1.9 +pycparser==2.14 # via cffi +pygments==2.1.3 +pymongo==3.7.2 +pyopenssl==18.0.0 +pyquery==1.4.0 +python-dateutil==2.7.5 +python-digest==1.7 +pytz==2018.9 +pyxform==0.12.0 +redis==3.1.0 # via django-redis-sessions +requests==2.21.0 +responses==0.9.0 +s3transfer==0.1.13 # via boto3 +scandir==1.9.0 # via pathlib2 +shortuuid==0.4.3 +six==1.12.0 +sqlparse==0.1.19 +static3==0.7.0 +statistics==1.0.3.5 +tabulate==0.8.2 +>>>>>>> Use Redis as session storage traceback2==1.4.0 # via unittest2 unicodecsv==0.14.1 # via -r dependencies/pip/requirements.in, pyxform unittest2==1.1.0 # via pyxform diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 7e2b71b5ee..786bafb3c5 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -227,7 +227,14 @@ def __init__(self, *args, **kwargs): if kobocat_database_url: DATABASES['kobocat'] = dj_database_url.parse(kobocat_database_url) +<<<<<<< HEAD DATABASE_ROUTERS = ['kpi.db_routers.DefaultDatabaseRouter'] +======= +# Tmp hack to point DB to kc_kobo @to-Do remove when kobo-docker supports it +DATABASES['kobocat']['NAME'] = "kc_kobo" + +DATABASE_ROUTERS = ["kpi.db_routers.DefaultDatabaseRouter"] +>>>>>>> Use Redis as session storage # Internationalization # https://docs.djangoproject.com/en/1.8/topics/i18n/ @@ -697,11 +704,6 @@ def __init__(self, *args, **kwargs): MONGO_CONNECTION_URL, j=True, tz_aware=True, connect=False) MONGO_DB = MONGO_CONNECTION[MONGO_DATABASE['NAME']] + SESSION_ENGINE = "redis_sessions.session" SESSION_REDIS = RedisHelper.config(default="redis://redis_cache:6380/2") - -# The maximum size in bytes that a request body may be before a SuspiciousOperation (RequestDataTooBig) is raised -DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 - -# The maximum size (in bytes) that an upload will be before it gets streamed to the file system -FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 diff --git a/kpi/utils/redis_helper.py b/kpi/utils/redis_helper.py index 1ff7991d40..27afa351af 100644 --- a/kpi/utils/redis_helper.py +++ b/kpi/utils/redis_helper.py @@ -1,12 +1,11 @@ -# coding: utf-8 +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + import os import re -from urllib.parse import unquote_plus - -from django.core.exceptions import ImproperlyConfigured -class RedisHelper: +class RedisHelper(object): """ Redis's helper. @@ -17,30 +16,24 @@ class RedisHelper: @staticmethod def config(default=None): """ - Parses `REDIS_SESSION_URL` environment variable to return a dict with - expected attributes for django redis session. - :return: dict """ - redis_connection_url = os.getenv('REDIS_SESSION_URL', default) - match = re.match(r'redis://(:(?P[^@]*)@)?(?P[^:]+):(?P\d+)(/(?P\d+))?', - redis_connection_url) - if not match: - raise ImproperlyConfigured("Could not parse Redis session URL. " - "Please verify 'REDIS_SESSION_URL' value") - - if match.group('password') is None: - password = None - else: - password = unquote_plus(match.group('password')) - - redis_connection_dict = { - 'host': match.group('host'), - 'port': match.group('port'), - 'db': match.group('index') or 0, - 'password': password, - 'prefix': os.getenv('REDIS_SESSION_PREFIX', 'session'), - 'socket_timeout': os.getenv('REDIS_SESSION_SOCKET_TIMEOUT', 1), - } - return redis_connection_dict + try: + redis_connection_url = os.getenv("REDIS_SESSION_URL", default) + match = re.match(r"redis://(:(?P[^@]*)@)?(?P[^:]+):(?P\d+)(/(?P\d+))?", + redis_connection_url) + if not match: + raise Exception() + + redis_connection_dict = { + "host": match.group("host"), + "port": match.group("port"), + "db": match.group("index") or 0, + "password": match.group("password"), + "prefix": os.getenv("REDIS_SESSION_PREFIX", "session"), + "socket_timeout": os.getenv("REDIS_SESSION_SOCKET_TIMEOUT", 1), + } + return redis_connection_dict + except Exception as e: + raise Exception("Could not parse Redis session URL. Please verify 'REDIS_SESSION_URL' value") From 4206c6fd7f229ca261db050cd7dbd4fbb63bf44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 15 Feb 2019 14:26:11 -0500 Subject: [PATCH 003/499] Used signals to sync 'kc' DB with 'kpi' DB --- kobo/settings/base.py | 4 - .../kc_access/shadow_models.py | 76 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 786bafb3c5..125e7ce545 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -227,14 +227,10 @@ def __init__(self, *args, **kwargs): if kobocat_database_url: DATABASES['kobocat'] = dj_database_url.parse(kobocat_database_url) -<<<<<<< HEAD -DATABASE_ROUTERS = ['kpi.db_routers.DefaultDatabaseRouter'] -======= # Tmp hack to point DB to kc_kobo @to-Do remove when kobo-docker supports it DATABASES['kobocat']['NAME'] = "kc_kobo" DATABASE_ROUTERS = ["kpi.db_routers.DefaultDatabaseRouter"] ->>>>>>> Use Redis as session storage # Internationalization # https://docs.djangoproject.com/en/1.8/topics/i18n/ diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index 7d8a6afc74..d54086f77d 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -13,8 +13,14 @@ ) from django.utils import timezone from django.utils.translation import ugettext_lazy as _ +<<<<<<< HEAD from django_digest.models import PartialDigest from jsonfield import JSONField +======= +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.utils import timezone +>>>>>>> Used signals to sync 'kc' DB with 'kpi' DB from kpi.constants import SHADOW_MODEL_APP_LABEL from kpi.utils.strings import hashable_str @@ -204,6 +210,7 @@ class KobocatUser(ShadowModel): class Meta(ShadowModel.Meta): db_table = "auth_user" +<<<<<<< HEAD @classmethod def sync(cls, auth_user): # NB: `KobocatUserObjectPermission` (and probably other things) depend @@ -236,6 +243,9 @@ def sync(cls, auth_user): class KobocatUserObjectPermission(ShadowModel): +======= +class UserObjectPermission(ShadowModel): +>>>>>>> Used signals to sync 'kc' DB with 'kpi' DB """ For the _sole purpose_ of letting us manipulate KoBoCAT permissions, this comprises the following django-guardian classes @@ -249,8 +259,13 @@ class KobocatUserObjectPermission(ShadowModel): CAVEAT LECTOR: The django-guardian custom manager, UserObjectPermissionManager, is NOT included! """ +<<<<<<< HEAD permission = models.ForeignKey(KobocatPermission, on_delete=models.CASCADE) content_type = models.ForeignKey(KobocatContentType, on_delete=models.CASCADE) +======= + permission = models.ForeignKey(Permission) + content_type = models.ForeignKey(ContentType) +>>>>>>> Used signals to sync 'kc' DB with 'kpi' DB object_pk = models.CharField(_('object ID'), max_length=255) content_object = GenericForeignKey(fk_field='object_pk') # It's okay not to use `KobocatUser` as long as PKs are synchronized @@ -389,6 +404,67 @@ def sync(cls, user): ) +class KCUser(ShadowModel): + + username = models.CharField(_("username"), max_length=30) + password = models.CharField(_("password"), max_length=128) + last_login = models.DateTimeField(_("last login"), blank=True, null=True) + is_superuser = models.BooleanField(_('superuser status'), default=False) + first_name = models.CharField(_('first name'), max_length=30, blank=True) + last_name = models.CharField(_('last name'), max_length=150, blank=True) + email = models.EmailField(_('email address'), blank=True) + is_staff = models.BooleanField(_('staff status'), default=False) + is_active = models.BooleanField(_('active'), default=True) + date_joined = models.DateTimeField(_('date joined'), default=timezone.now) + + class Meta(ShadowModel.Meta): + db_table = "auth_user" + + @classmethod + def sync(cls, auth_user): + try: + kc_auth_user = cls.objects.get(pk=auth_user.pk) + assert kc_auth_user.username == auth_user.username + except KCUser.DoesNotExist: + kc_auth_user = cls(pk=auth_user.pk, username=auth_user.username) + + kc_auth_user.password = auth_user.password + kc_auth_user.last_login = auth_user.last_login + kc_auth_user.is_superuser = auth_user.is_superuser + kc_auth_user.first_name = auth_user.first_name + kc_auth_user.last_name = auth_user.last_name + kc_auth_user.email = auth_user.email + kc_auth_user.is_staff = auth_user.is_staff + kc_auth_user.is_active = auth_user.is_active + kc_auth_user.date_joined = auth_user.date_joined + + kc_auth_user.save() + + +class KCToken(ShadowModel): + + key = models.CharField(_("Key"), max_length=40, primary_key=True) + user = models.OneToOneField(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), + related_name='auth_token', + on_delete=models.CASCADE, verbose_name=_("User")) + created = models.DateTimeField(_("Created"), auto_now_add=True) + + class Meta(ShadowModel.Meta): + db_table = "authtoken_token" + + @classmethod + def sync(cls, auth_token): + try: + kc_auth_token = cls.objects.get(pk=auth_token.pk) + assert kc_auth_user.user_id == auth_token.user_id + except KCToken.DoesNotExist: + kc_auth_token = cls(pk=auth_token.pk, user=auth_token.user) + + print("####") + print("ON SAVE LE KCTOKEN") + kc_auth_token.save() + + def safe_kc_read(func): def _wrapper(*args, **kwargs): try: From 5d251a91bef1a3b1e2a390245277f1fdb67a666c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 15 Feb 2019 15:01:57 -0500 Subject: [PATCH 004/499] Removed useless debug print --- kobo/settings/base.py | 12 +++++++----- kpi/signals.py | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 125e7ce545..7e2b71b5ee 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -227,10 +227,7 @@ def __init__(self, *args, **kwargs): if kobocat_database_url: DATABASES['kobocat'] = dj_database_url.parse(kobocat_database_url) -# Tmp hack to point DB to kc_kobo @to-Do remove when kobo-docker supports it -DATABASES['kobocat']['NAME'] = "kc_kobo" - -DATABASE_ROUTERS = ["kpi.db_routers.DefaultDatabaseRouter"] +DATABASE_ROUTERS = ['kpi.db_routers.DefaultDatabaseRouter'] # Internationalization # https://docs.djangoproject.com/en/1.8/topics/i18n/ @@ -700,6 +697,11 @@ def __init__(self, *args, **kwargs): MONGO_CONNECTION_URL, j=True, tz_aware=True, connect=False) MONGO_DB = MONGO_CONNECTION[MONGO_DATABASE['NAME']] - SESSION_ENGINE = "redis_sessions.session" SESSION_REDIS = RedisHelper.config(default="redis://redis_cache:6380/2") + +# The maximum size in bytes that a request body may be before a SuspiciousOperation (RequestDataTooBig) is raised +DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 + +# The maximum size (in bytes) that an upload will be before it gets streamed to the file system +FILE_UPLOAD_MAX_MEMORY_SIZE = 10485760 diff --git a/kpi/signals.py b/kpi/signals.py index 9bd7cee6d1..835df2d6bd 100644 --- a/kpi/signals.py +++ b/kpi/signals.py @@ -1,9 +1,9 @@ + # coding: utf-8 from django.conf import settings from django.contrib.auth.models import User from django.db.models.signals import post_save, post_delete from django.dispatch import receiver -from django_digest.models import PartialDigest from rest_framework.authtoken.models import Token from taggit.models import Tag @@ -11,13 +11,13 @@ from kpi.deployment_backends.kc_access.shadow_models import ( KobocatToken, KobocatUser, - KobocatDigestPartial ) from kpi.deployment_backends.kc_access.utils import grant_kc_model_level_perms -from kpi.models import Asset, Collection, ObjectPermission, TagUid +from kpi.models import Asset, TagUid from kpi.utils.permissions import grant_default_model_level_perms + @receiver(post_save, sender=User) def default_permissions_post_save(sender, instance, created, raw, **kwargs): """ From 1c3307f7dbb2291e91521c8edd4f23ba8d222318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Mon, 25 Feb 2019 14:48:14 -0500 Subject: [PATCH 005/499] Removed useless debug print --- .../kc_access/shadow_models.py | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index d54086f77d..121dac8b7a 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -4,7 +4,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey -from django.core.exceptions import ValidationError +from django.contrib.postgres.fields import JSONField as JSONBField from django.db import ( ProgrammingError, connections, @@ -13,16 +13,10 @@ ) from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -<<<<<<< HEAD from django_digest.models import PartialDigest -from jsonfield import JSONField -======= -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.utils import timezone ->>>>>>> Used signals to sync 'kc' DB with 'kpi' DB from kpi.constants import SHADOW_MODEL_APP_LABEL +from kpi.exceptions import BadContentTypeException from kpi.utils.strings import hashable_str @@ -210,7 +204,6 @@ class KobocatUser(ShadowModel): class Meta(ShadowModel.Meta): db_table = "auth_user" -<<<<<<< HEAD @classmethod def sync(cls, auth_user): # NB: `KobocatUserObjectPermission` (and probably other things) depend @@ -243,9 +236,6 @@ def sync(cls, auth_user): class KobocatUserObjectPermission(ShadowModel): -======= -class UserObjectPermission(ShadowModel): ->>>>>>> Used signals to sync 'kc' DB with 'kpi' DB """ For the _sole purpose_ of letting us manipulate KoBoCAT permissions, this comprises the following django-guardian classes @@ -259,13 +249,8 @@ class UserObjectPermission(ShadowModel): CAVEAT LECTOR: The django-guardian custom manager, UserObjectPermissionManager, is NOT included! """ -<<<<<<< HEAD permission = models.ForeignKey(KobocatPermission, on_delete=models.CASCADE) content_type = models.ForeignKey(KobocatContentType, on_delete=models.CASCADE) -======= - permission = models.ForeignKey(Permission) - content_type = models.ForeignKey(ContentType) ->>>>>>> Used signals to sync 'kc' DB with 'kpi' DB object_pk = models.CharField(_('object ID'), max_length=255) content_object = GenericForeignKey(fk_field='object_pk') # It's okay not to use `KobocatUser` as long as PKs are synchronized @@ -460,8 +445,6 @@ def sync(cls, auth_token): except KCToken.DoesNotExist: kc_auth_token = cls(pk=auth_token.pk, user=auth_token.user) - print("####") - print("ON SAVE LE KCTOKEN") kc_auth_token.save() From 447a2c5295c23dfbb515e53938d6b680d618c493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 24 Apr 2019 09:54:37 -0400 Subject: [PATCH 006/499] PEP-8 fixes --- kpi/models/asset_file.py | 1 + kpi/tests/test_permissions.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/kpi/models/asset_file.py b/kpi/models/asset_file.py index a5bc86142e..c0054adb16 100644 --- a/kpi/models/asset_file.py +++ b/kpi/models/asset_file.py @@ -9,6 +9,7 @@ from kpi.fields import KpiUidField + def upload_to(self, filename): """ Please note that due to Python 2 limitations, you cannot serialize unbound diff --git a/kpi/tests/test_permissions.py b/kpi/tests/test_permissions.py index 83284e8c43..916b638867 100644 --- a/kpi/tests/test_permissions.py +++ b/kpi/tests/test_permissions.py @@ -68,7 +68,11 @@ def _test_remove_perm(self, obj, perm_name_prefix, user): :type perm_name_prefix: str :param user: The user for whom permissions on `obj` will be manipulated. :type user: :py:class:`User` +<<<<<<< HEAD """ +======= + ''' +>>>>>>> PEP-8 fixes perm_name = self._get_perm_name(perm_name_prefix, obj) self.assertTrue(user.has_perm(perm_name, obj)) obj.remove_perm(user, perm_name) From b7bba0e064424c622c1f0e737312cb3543633e2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 24 Apr 2019 09:56:51 -0400 Subject: [PATCH 007/499] Added new permission and related model for row level permission --- ...ate_many_to_many_asset_user_permissions.py | 42 ++ ...0024_add_new_supervisor_view_submission.py | 36 ++ kpi/models/asset.py | 525 ++++++++++++------ .../asset_user_supervisor_permissions.py | 40 ++ 4 files changed, 475 insertions(+), 168 deletions(-) create mode 100644 kpi/migrations/0023_create_many_to_many_asset_user_permissions.py create mode 100644 kpi/migrations/0024_add_new_supervisor_view_submission.py create mode 100644 kpi/models/asset_user_supervisor_permissions.py diff --git a/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py b/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py new file mode 100644 index 0000000000..fcecf00097 --- /dev/null +++ b/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import kpi.models.asset_file +import private_storage.storage.s3boto3 +from django.conf import settings +import django.utils.timezone +import private_storage.fields +import kpi.models.import_export_task +import jsonbfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('kpi', '0022_assetfile'), + ] + + operations = [ + migrations.CreateModel( + name='AssetUserSupervisorPermissions', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('permissions', jsonbfield.fields.JSONField(default=dict)), + ('date_created', models.DateTimeField(default=django.utils.timezone.now)), + ('date_modified', models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + migrations.AddField( + model_name='assetusersupervisorpermissions', + name='asset', + field=models.ForeignKey(related_name='asset_supervisor_permissions', to='kpi.Asset'), + ), + migrations.AddField( + model_name='assetusersupervisorpermissions', + name='user', + field=models.ForeignKey(related_name='user_supervisor_permissions', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/kpi/migrations/0024_add_new_supervisor_view_submission.py b/kpi/migrations/0024_add_new_supervisor_view_submission.py new file mode 100644 index 0000000000..0337f346eb --- /dev/null +++ b/kpi/migrations/0024_add_new_supervisor_view_submission.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import kpi.models.asset_file +import private_storage.fields +import kpi.models.import_export_task +import private_storage.storage.s3boto3 + + +class Migration(migrations.Migration): + dependencies = [ + ('kpi', '0023_create_many_to_many_asset_user_permissions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='asset', + options={ + 'ordering': ('-date_modified',), + 'permissions': ( + ('view_asset', 'Can view asset'), + ('share_asset', "Can change asset's sharing settings"), + ('add_submissions', 'Can submit data to asset'), + ('view_submissions', 'Can view submitted data for asset'), + ('supervisor_view_submissions', 'Can view submitted data for asset for specific users'), + ('change_submissions', 'Can modify submitted data for asset'), + ('delete_submissions', 'Can delete submitted data for asset'), + ('share_submissions', "Can change sharing settings for asset's submitted data"), + ('validate_submissions', 'Can validate submitted data asset'), + ('from_kc_only', 'INTERNAL USE ONLY; DO NOT ASSIGN') + ) + }, + ), + ] diff --git a/kpi/models/asset.py b/kpi/models/asset.py index ea3c93a817..5037fb910a 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -1,23 +1,22 @@ # coding: utf-8 # 😬 -import re import copy import sys from collections import OrderedDict +from functools import reduce from io import BytesIO +from operator import add +from typing import Union import six import xlsxwriter +from django.conf import settings from django.contrib.auth.models import Permission -from django.core.exceptions import ValidationError -from django.contrib.contenttypes.fields import GenericRelation -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import JSONField as JSONBField from django.db import models from django.db import transaction -from django.db.models import Exists, OuterRef, Prefetch +from django.db.models import Exists, OuterRef, Prefetch, Q from django.utils.translation import ugettext_lazy as _ -from jsonfield import JSONField from taggit.managers import TaggableManager, _TaggableManager from taggit.utils import require_instance_manager @@ -29,7 +28,9 @@ DEFAULT_REPORTS_KEY) from kpi.constants import ( ASSET_TYPES, + ASSET_TYPES_WITH_CONTENT, ASSET_TYPE_BLOCK, + ASSET_TYPE_COLLECTION, ASSET_TYPE_EMPTY, ASSET_TYPE_QUESTION, ASSET_TYPE_SURVEY, @@ -37,17 +38,15 @@ ASSET_TYPE_TEXT, PERM_ADD_SUBMISSIONS, PERM_CHANGE_ASSET, - PERM_CHANGE_COLLECTION, PERM_CHANGE_SUBMISSIONS, PERM_DELETE_ASSET, PERM_DELETE_SUBMISSIONS, + PERM_DISCOVER_ASSET, PERM_FROM_KC_ONLY, + PERM_MANAGE_ASSET, PERM_PARTIAL_SUBMISSIONS, - PERM_SHARE_ASSET, - PERM_SHARE_SUBMISSIONS, PERM_VALIDATE_SUBMISSIONS, PERM_VIEW_ASSET, - PERM_VIEW_COLLECTION, PERM_VIEW_SUBMISSIONS, SUFFIX_SUBMISSIONS_PERMS, ) @@ -77,19 +76,49 @@ standardize_content_in_place) from .asset_user_partial_permission import AssetUserPartialPermission from .asset_version import AssetVersion -from .object_permission import ObjectPermission, ObjectPermissionMixin +from .object_permission import ObjectPermissionMixin, get_cached_code_names # TODO: Would prefer this to be a mixin that didn't derive from `Manager`. -class TaggableModelManager(models.Manager): +class AssetManager(models.Manager): + def create(self, *args, children_to_create=None, tag_string=None, **kwargs): + update_parent_languages = kwargs.pop('update_parent_languages', True) + + # 3 lines below are copied from django.db.models.query.QuerySet.create() + # because we need to pass an argument to save() + # (and the default Django create() does not allow that) + created = self.model(**kwargs) + self._for_write = True + created.save(force_insert=True, using=self.db, + update_parent_languages=update_parent_languages) - def create(self, *args, **kwargs): - tag_string = kwargs.pop('tag_string', None) - created = super().create(*args, **kwargs) if tag_string: created.tag_string = tag_string + if children_to_create: + new_assets = [] + for asset in children_to_create: + asset['parent'] = created + new_assets.append(Asset.objects.create( + update_parent_languages=False, **asset)) + created.update_languages(new_assets) return created + def deployed(self): + """ + Filter for deployed assets (i.e. assets having at least one deployed + version) in an efficient way that doesn't involve joining or counting. + https://docs.djangoproject.com/en/2.2/ref/models/expressions/#django.db.models.Exists + """ + deployed_versions = AssetVersion.objects.filter( + asset=OuterRef('pk'), deployed=True + ) + return self.annotate(deployed=Exists(deployed_versions)).filter( + deployed=True + ) + + def filter_by_tag_name(self, tag_name): + return self.filter(tags__name=tag_name) + class KpiTaggableManager(_TaggableManager): @require_instance_manager @@ -110,44 +139,6 @@ def add(self, *tags, **kwargs): super().add(*tags_out, **kwargs) -class AssetManager(TaggableModelManager): - def deployed(self): - """ - Filter for deployed assets (i.e. assets having at least one deployed - version) in an efficient way that doesn't involve joining or counting. - https://docs.djangoproject.com/en/2.2/ref/models/expressions/#django.db.models.Exists - """ - deployed_versions = AssetVersion.objects.filter( - asset=OuterRef('pk'), deployed=True - ) - return self.annotate(deployed=Exists(deployed_versions)).filter( - deployed=True - ) - - def filter_by_tag_name(self, tag_name): - return self.filter(tags__name=tag_name) - - -# TODO: Merge this functionality into the eventual common base class of `Asset` -# and `Collection`. -class TagStringMixin: - - @property - def tag_string(self): - try: - tag_list = self.prefetched_tags - except AttributeError: - tag_names = self.tags.values_list('name', flat=True) - else: - tag_names = [t.name for t in tag_list] - return ','.join(tag_names) - - @tag_string.setter - def tag_string(self, value): - intended_tags = value.split(',') - self.tags.set(*intended_tags) - - FLATTEN_OPTS = { 'remove_columns': { 'survey': [ @@ -184,14 +175,14 @@ def _expand_kobo_qs(self, content): def _ensure_settings(self, content): # asset.settings should exist already, but # on some legacy forms it might not - _settings = content.get('settings', {}) + _settings = OrderedDict(content.get('settings', {})) if isinstance(_settings, list): if len(_settings) > 0: - _settings = _settings[0] + _settings = OrderedDict(_settings[0]) else: - _settings = {} + _settings = OrderedDict() if not isinstance(_settings, dict): - _settings = {} + _settings = OrderedDict() content['settings'] = _settings def _append(self, content, **sheet_data): @@ -392,16 +383,6 @@ def _rename_translation(self, content, _from, _to): raise ValueError('Duplicate translation: {}'.format(_to)) _ts[_ts.index(_from)] = _to - def _contains_invalid_chars(self, content): - for row in content['survey']: - try: - if row['default'] and bool(re.search( - r'[<|>|&]', row['default'])): - raise ValidationError( - 'XForm questions settings may contain malicious content') - except KeyError: - pass - class XlsExportable: def ordered_xlsform_content(self, @@ -431,15 +412,17 @@ def to_xls_io(self, versioned=False, **kwargs): `{'settings': {'setting name': 'setting value'}}` """ if versioned: - append = kwargs['append'] = kwargs.get('append', {}) - append_survey = append['survey'] = append.get('survey', []) - append_settings = append['settings'] = append.get('settings', {}) + append = kwargs.setdefault('append', {}) + append_survey = append.setdefault('survey', []) + # We want to keep the order and append `version` at the end. + append_settings = OrderedDict(append.setdefault('settings', {})) append_survey.append( {'name': '__version__', 'calculation': '\'{}\''.format(self.version_id), 'type': 'calculate'} ) - append_settings.update({'version': self.version_id}) + append_settings.update({'version': self.version_number_and_date}) + kwargs['append']['settings'] = append_settings try: def _add_contents_to_sheet(sheet, contents): cols = [] @@ -478,7 +461,6 @@ def _add_contents_to_sheet(sheet, contents): class Asset(ObjectPermissionMixin, - TagStringMixin, DeployableMixin, XlsExportable, FormpackXLSFormUtils, @@ -486,28 +468,25 @@ class Asset(ObjectPermissionMixin, name = models.CharField(max_length=255, blank=True, default='') date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) - content = JSONField(default=dict) - summary = JSONField(default=dict) + content = JSONBField(default=dict) + summary = JSONBField(default=dict) report_styles = JSONBField(default=dict) report_custom = JSONBField(default=dict) map_styles = LazyDefaultJSONBField(default=dict) map_custom = LazyDefaultJSONBField(default=dict) asset_type = models.CharField( choices=ASSET_TYPES, max_length=20, default=ASSET_TYPE_SURVEY) - parent = models.ForeignKey('Collection', related_name='assets', + parent = models.ForeignKey('Asset', related_name='children', null=True, blank=True, on_delete=models.CASCADE) owner = models.ForeignKey('auth.User', related_name='assets', null=True, on_delete=models.CASCADE) - editors_can_change_permissions = models.BooleanField(default=True) uid = KpiUidField(uid_prefix='a') tags = TaggableManager(manager=KpiTaggableManager) settings = JSONBField(default=dict) # _deployment_data should be accessed through the `deployment` property # provided by `DeployableMixin` - _deployment_data = JSONField(default=dict) - - permissions = GenericRelation(ObjectPermission) + _deployment_data = JSONBField(default=dict) objects = AssetManager() @@ -516,13 +495,24 @@ def kind(self): return 'asset' class Meta: - ordering = ('-date_modified',) + + # Example in Django documentation represents `ordering` as a list + # (even if it can be a list or a tuple). We enforce the type to `list` + # because `rest_framework.filters.OrderingFilter` work with lists. + # `AssetOrderingFilter` inherits from this class and it is used ` + # in `AssetViewSet to sort the result. + # It avoids back and forth between types and/or coercing where + # ordering is needed + ordering = [ + '-date_modified', + ] permissions = ( # change_, add_, and delete_asset are provided automatically # by Django (PERM_VIEW_ASSET, _('Can view asset')), - (PERM_SHARE_ASSET, _("Can change asset's sharing settings")), + (PERM_DISCOVER_ASSET, _('Can discover asset in public lists')), + (PERM_MANAGE_ASSET, _('Can manage all aspects of asset')), # Permissions for collected data, i.e. submissions (PERM_ADD_SUBMISSIONS, _('Can submit data to asset')), (PERM_VIEW_SUBMISSIONS, _('Can view submitted data for asset')), @@ -531,8 +521,6 @@ class Meta: 'for specific users')), (PERM_CHANGE_SUBMISSIONS, _('Can modify submitted data for asset')), (PERM_DELETE_SUBMISSIONS, _('Can delete submitted data for asset')), - (PERM_SHARE_SUBMISSIONS, _("Can change sharing settings for " - "asset's submitted data")), (PERM_VALIDATE_SUBMISSIONS, _("Can validate submitted data asset")), # TEMPORARY Issue #1161: A flag to indicate that permissions came # solely from `sync_kobocat_xforms` and not from any user @@ -551,15 +539,19 @@ class Meta: # The simplest way to fix this is to keep old behaviour default_permissions = ('add', 'change', 'delete') - # Labels for each `asset_type` as they should be presented to users - ASSET_TYPE_LABELS = { - ASSET_TYPE_SURVEY: _('form'), + # Labels for each `asset_type` as they should be presented to users. Can be + # strings or callables if special logic is needed. Callables receive the + # codename of the permission for which a label is being created + ASSET_TYPE_LABELS_FOR_PERMISSIONS = { + ASSET_TYPE_SURVEY: ( + lambda p: _('project') if p == PERM_MANAGE_ASSET else _('form') + ), ASSET_TYPE_TEMPLATE: _('template'), ASSET_TYPE_BLOCK: _('block'), ASSET_TYPE_QUESTION: _('question'), ASSET_TYPE_TEXT: _('text'), # unused? ASSET_TYPE_EMPTY: _('empty'), # unused? - #ASSET_TYPE_COLLECTION: _('collection'), + ASSET_TYPE_COLLECTION: _('collection'), } # Assignable permissions that are stored in the database. @@ -568,61 +560,104 @@ class Meta: ASSIGNABLE_PERMISSIONS_WITH_LABELS = { PERM_VIEW_ASSET: _('View ##asset_type_label##'), PERM_CHANGE_ASSET: _('Edit ##asset_type_label##'), + PERM_DISCOVER_ASSET: _('Discover ##asset_type_label##'), + PERM_MANAGE_ASSET: _('Manage ##asset_type_label##'), PERM_ADD_SUBMISSIONS: _('Add submissions'), PERM_VIEW_SUBMISSIONS: _('View submissions'), PERM_PARTIAL_SUBMISSIONS: _('View submissions only from specific users'), - PERM_CHANGE_SUBMISSIONS: _('Edit and delete submissions'), + PERM_CHANGE_SUBMISSIONS: _('Edit submissions'), + PERM_DELETE_SUBMISSIONS: _('Delete submissions'), PERM_VALIDATE_SUBMISSIONS: _('Validate submissions'), } ASSIGNABLE_PERMISSIONS = tuple(ASSIGNABLE_PERMISSIONS_WITH_LABELS.keys()) # Depending on our `asset_type`, only some permissions might be applicable ASSIGNABLE_PERMISSIONS_BY_TYPE = { - ASSET_TYPE_SURVEY: ASSIGNABLE_PERMISSIONS, # all of them - ASSET_TYPE_TEMPLATE: (PERM_VIEW_ASSET, PERM_CHANGE_ASSET), - ASSET_TYPE_BLOCK: (PERM_VIEW_ASSET, PERM_CHANGE_ASSET), - ASSET_TYPE_QUESTION: (PERM_VIEW_ASSET, PERM_CHANGE_ASSET), + ASSET_TYPE_SURVEY: tuple( + (p for p in ASSIGNABLE_PERMISSIONS if p != PERM_DISCOVER_ASSET) + ), + ASSET_TYPE_TEMPLATE: ( + PERM_VIEW_ASSET, + PERM_CHANGE_ASSET, + PERM_MANAGE_ASSET, + ), + ASSET_TYPE_BLOCK: ( + PERM_VIEW_ASSET, + PERM_CHANGE_ASSET, + PERM_MANAGE_ASSET, + ), + ASSET_TYPE_QUESTION: ( + PERM_VIEW_ASSET, + PERM_CHANGE_ASSET, + PERM_MANAGE_ASSET, + ), ASSET_TYPE_TEXT: (), # unused? - ASSET_TYPE_EMPTY: (), # unused? - #ASSET_TYPE_COLLECTION: # tbd + ASSET_TYPE_EMPTY: ( + PERM_VIEW_ASSET, + PERM_CHANGE_ASSET, + PERM_MANAGE_ASSET, + ), + ASSET_TYPE_COLLECTION: ( + PERM_VIEW_ASSET, + PERM_CHANGE_ASSET, + PERM_DISCOVER_ASSET, + PERM_MANAGE_ASSET, + ), } # Calculated permissions that are neither directly assignable nor stored # in the database, but instead implied by assignable permissions CALCULATED_PERMISSIONS = ( - PERM_SHARE_ASSET, PERM_DELETE_ASSET, - PERM_SHARE_SUBMISSIONS, - PERM_DELETE_SUBMISSIONS ) - # Certain Collection permissions carry over to Asset - MAPPED_PARENT_PERMISSIONS = { - PERM_VIEW_COLLECTION: PERM_VIEW_ASSET, - PERM_CHANGE_COLLECTION: PERM_CHANGE_ASSET + # Only certain permissions can be inherited + HERITABLE_PERMISSIONS = { + # parent permission: child permission + PERM_VIEW_ASSET: PERM_VIEW_ASSET, + PERM_CHANGE_ASSET: PERM_CHANGE_ASSET } # Granting some permissions implies also granting other permissions IMPLIED_PERMISSIONS = { # Format: explicit: (implied, implied, ...) PERM_CHANGE_ASSET: (PERM_VIEW_ASSET,), + PERM_DISCOVER_ASSET: (PERM_VIEW_ASSET,), + PERM_MANAGE_ASSET: tuple( + ( + p + for p in ASSIGNABLE_PERMISSIONS + if p not in (PERM_MANAGE_ASSET, PERM_PARTIAL_SUBMISSIONS) + ) + ), PERM_ADD_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_VIEW_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_PARTIAL_SUBMISSIONS: (PERM_VIEW_ASSET,), - PERM_CHANGE_SUBMISSIONS: (PERM_VIEW_SUBMISSIONS,), - PERM_VALIDATE_SUBMISSIONS: (PERM_VIEW_SUBMISSIONS,) + PERM_CHANGE_SUBMISSIONS: ( + PERM_VIEW_SUBMISSIONS, + PERM_ADD_SUBMISSIONS, + ), + PERM_DELETE_SUBMISSIONS: (PERM_VIEW_SUBMISSIONS,), + PERM_VALIDATE_SUBMISSIONS: (PERM_VIEW_SUBMISSIONS,), } CONTRADICTORY_PERMISSIONS = { - PERM_PARTIAL_SUBMISSIONS: (PERM_VIEW_SUBMISSIONS, PERM_CHANGE_SUBMISSIONS, - PERM_VALIDATE_SUBMISSIONS), + PERM_PARTIAL_SUBMISSIONS: ( + PERM_VIEW_SUBMISSIONS, + PERM_CHANGE_SUBMISSIONS, + PERM_DELETE_SUBMISSIONS, + PERM_VALIDATE_SUBMISSIONS, + PERM_MANAGE_ASSET, + ), PERM_VIEW_SUBMISSIONS: (PERM_PARTIAL_SUBMISSIONS,), PERM_CHANGE_SUBMISSIONS: (PERM_PARTIAL_SUBMISSIONS,), - PERM_VALIDATE_SUBMISSIONS: (PERM_PARTIAL_SUBMISSIONS,) + PERM_DELETE_SUBMISSIONS: (PERM_PARTIAL_SUBMISSIONS,), + PERM_VALIDATE_SUBMISSIONS: (PERM_PARTIAL_SUBMISSIONS,), } # Some permissions must be copied to KC - KC_PERMISSIONS_MAP = { # keys are KC's codenames, values are KPI's + KC_PERMISSIONS_MAP = { # keys are KPI's codenames, values are KC's PERM_CHANGE_SUBMISSIONS: 'change_xform', # "Can Edit" in KC UI PERM_VIEW_SUBMISSIONS: 'view_xform', # "Can View" in KC UI PERM_ADD_SUBMISSIONS: 'report_xform', # "Can submit to" in KC UI + PERM_DELETE_SUBMISSIONS: 'delete_data_xform', # "Can Delete Data" in KC UI PERM_VALIDATE_SUBMISSIONS: 'validate_xform', # "Can Validate" in KC UI } KC_CONTENT_TYPE_KWARGS = {'app_label': 'logger', 'model': 'xform'} @@ -631,6 +666,10 @@ class Meta: PERM_VIEW_SUBMISSIONS: {'shared': True, 'shared_data': True} } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__previous_parent_id = self.parent_id + def __str__(self): return '{} ({})'.format(self.name, self.uid) @@ -648,7 +687,6 @@ def adjust_content_on_save(self): self._autoname(self.content) self._unlink_list_items(self.content) self._remove_empty_expressions(self.content) - self._contains_invalid_chars(self.content) settings = self.content['settings'] _title = settings.pop('form_title', None) @@ -671,20 +709,45 @@ def adjust_content_on_save(self): def clone(self, version_uid=None): # not currently used, but this is how "to_clone_dict" should work - return Asset.objects.create(**self.to_clone_dict(version_uid)) + return Asset.objects.create(**self.to_clone_dict(version=version_uid)) + + def create_version(self) -> [AssetVersion, None]: + """ + Create a version of current asset. + Asset has to belong to `ASSET_TYPE_WITH_CONTENT` otherwise no version + is created and `None` is returned. + """ + if self.asset_type not in ASSET_TYPES_WITH_CONTENT: + return + + return self.asset_versions.create( + name=self.name, + version_content=self.content, + _deployment_data=self._deployment_data, + # Any new version starts out as not-deployed, + # even if the asset itself is already deployed. + # Note: `asset_version.deployed` is set in the + # serializer `DeploymentSerializer` + deployed=False, + ) @property def deployed_versions(self): return self.asset_versions.filter(deployed=True).order_by( '-date_modified') - def get_ancestors_or_none(self): - # ancestors are ordered from farthest to nearest - if self.parent is not None: - return self.parent.get_ancestors(include_self=True) - else: + @property + def discoverable_when_public(self): + # This property is only needed when `self` is a collection. + # We want to make a distinction between a collection which is not + # discoverable and an asset which is not a collection + # (which implies cannot be discoverable) + if self.asset_type != ASSET_TYPE_COLLECTION: return None + return self.permissions.filter(permission__codename=PERM_DISCOVER_ASSET, + user_id=settings.ANONYMOUS_USER_ID).exists() + def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): """ Returns the list of filters for a specific permission `perm` @@ -702,43 +765,60 @@ def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): return perms.get(perm) return None - def get_label_for_permission(self, permission_or_codename): + def get_label_for_permission( + self, permission_or_codename: Union[Permission, str] + ) -> str: + """ + Get the correct label for a permission (object or codename) based on + the type of this asset + """ try: codename = permission_or_codename.codename permission = permission_or_codename except AttributeError: codename = permission_or_codename permission = None + try: label = self.ASSIGNABLE_PERMISSIONS_WITH_LABELS[codename] except KeyError: - if not permission: - # Seems expensive. Cache it? - permission = Permission.objects.filter( - content_type=ContentType.objects.get_for_model(self), - codename=codename - ) - label = permission.name + if permission: + label = permission.name + else: + cached_code_names = get_cached_code_names() + label = cached_code_names[codename]['name'] + + asset_type_label = self.ASSET_TYPE_LABELS_FOR_PERMISSIONS[ + self.asset_type + ] + try: + # Some labels may be callables + asset_type_label = asset_type_label(codename) + except TypeError: + # Others are just strings + pass + label = label.replace( '##asset_type_label##', # Raises TypeError if not coerced explicitly - str(self.ASSET_TYPE_LABELS[self.asset_type]) + str(asset_type_label) ) return label - def get_partial_perms(self, user_id, with_filters=False): + def get_partial_perms( + self, user_id: int, with_filters: bool = False + ) -> Union[list, dict, None]: """ Returns the list of permissions the user is restricted to, for this specific asset. - If `with_filters` is `True`, it returns a dict of permissions (as keys) and - the filters (as values) to apply on query to narrow down the results. - + If `with_filters` is `True`, it returns a dict of permissions (as keys) + and the filters (as values) to apply on query to narrow down + the results. For example: `get_partial_perms(user1_obj.id)` would return ``` ['view_submissions',] ``` - `get_partial_perms(user1_obj.id, with_filters=True)` would return ``` { @@ -748,13 +828,7 @@ def get_partial_perms(self, user_id, with_filters=False): ], } ``` - If user doesn't have any partial permissions, it returns `None`. - - :param user_obj: auth.User - :param with_filters: boolean. Optional - - :return: list|dict|None """ perms = self.asset_partial_permissions.filter(user_id=user_id)\ @@ -777,6 +851,17 @@ def has_active_hooks(self): """ return self.hooks.filter(active=True).exists() + def has_subscribed_user(self, user_id): + # This property is only needed when `self` is a collection. + # We want to make a distinction between a collection which does not have + # the subscribed user and an asset which is not a collection + # (which implies cannot have subscriptions) + if self.asset_type != ASSET_TYPE_COLLECTION: + return None + + # ToDo: See if using a loop can reduce the number of SQL queries. + return self.userassetsubscription_set.filter(user_id=user_id).exists() + @property def latest_deployed_version(self): return self.deployed_versions.first() @@ -797,20 +882,16 @@ def latest_version(self): def optimize_queryset_for_list(queryset): """ Used by serializers to improve performance when listing assets """ queryset = queryset.defer( - # Avoid pulling these `JSONField`s from the database because: - # * they are stored as plain text, and just deserializing them - # to Python objects is CPU-intensive; - # * they are often huge; - # * we don't need them for list views. + # Avoid pulling these from the database because they are often huge + # and we don't need them for list views. 'content', 'report_styles' ).select_related( + # We only need `username`, but `select_related('owner__username')` + # actually pulled in the entire `auth_user` table under Django 1.8. + # In Django 1.9+, "select_related() prohibits non-relational fields + # for nested relations." 'owner', ).prefetch_related( - # We previously prefetched `permissions__content_object`, but that - # actually pulled the entirety of each permission's linked asset - # from the database! For now, the solution is to remove - # `content_object` here *and* from - # `ObjectPermissionNestedSerializer`. 'permissions__permission', 'permissions__user', # `Prefetch(..., to_attr='prefetched_list')` stores the prefetched @@ -846,6 +927,17 @@ def revert_to_version(self, version_uid): self.save() def save(self, *args, **kwargs): + + is_new = self.pk is None + update_parent_languages = kwargs.pop('update_parent_languages', True) + + if self.asset_type not in ASSET_TYPES_WITH_CONTENT: + # so long as all of the operations in this overridden `save()` + # method pertain to content, bail out if it's impossible for this + # asset to have content in the first place + super().save(*args, **kwargs) + return + if self.content is None: self.content = {} @@ -874,33 +966,67 @@ def save(self, *args, **kwargs): _create_version = kwargs.pop('create_version', True) super().save(*args, **kwargs) + # Update languages for parent and previous parent. + # e.g. if an survey has been moved from one collection to another, + # we want both collections to be updated. + if self.parent is not None and update_parent_languages: + if self.parent_id != self.__previous_parent_id and \ + self.__previous_parent_id is not None: + try: + previous_parent = Asset.objects.get( + pk=self.__previous_parent_id) + previous_parent.update_languages() + self.__previous_parent_id = self.parent_id + except Asset.DoesNotExist: + pass + + # If object is new, we can add its languages to its parent without + # worrying about removing its old values. It avoids an extra query. + if is_new: + self.parent.update_languages([self]) + else: + # Otherwise, because we cannot know which languages are from + # this object, update will be perform with all parent's children. + self.parent.update_languages() + if _create_version: - self.asset_versions.create(name=self.name, - version_content=self.content, - _deployment_data=self._deployment_data, - # asset_version.deployed is set in the - # DeploymentSerializer - deployed=False, - ) + self.create_version() @property def snapshot(self): return self._snapshot(regenerate=False) - def to_clone_dict(self, version_uid=None, version=None): + @property + def tag_string(self): + try: + tag_list = self.prefetched_tags + except AttributeError: + tag_names = self.tags.values_list('name', flat=True) + else: + tag_names = [t.name for t in tag_list] + return ','.join(tag_names) + + @tag_string.setter + def tag_string(self, value): + intended_tags = value.split(',') + self.tags.set(*intended_tags) + + def to_clone_dict( + self, + version: Union[str, AssetVersion] = None + ) -> dict: """ - Returns a dictionary of the asset based on version_uid or version. - If `version` is specified, there are no needs to provide `version_uid` and make another request to DB. - :param version_uid: string - :param version: AssetVersion - :return: dict + Returns a dictionary of the asset based on its version. + :param version: Optional. It can be an object or its unique id + :return dict """ - if not isinstance(version, AssetVersion): - if version_uid: - version = self.asset_versions.get(uid=version_uid) + if version: + version = self.asset_versions.get(uid=version) else: version = self.asset_versions.first() + if not version: + version = self.create_version() return { 'name': version.name, @@ -912,9 +1038,53 @@ def to_clone_dict(self, version_uid=None, version=None): def to_ss_structure(self): return flatten_content(self.content, in_place=False) + def update_languages(self, children=None): + """ + Updates object's languages by aggregating all its children's languages + Args: + children (list): Optional. When specified, `children`'s languages + are merged with `self`'s languages. Otherwise, when it's `None`, + DB is fetched to build the list according to `self.children` + """ + # If object is not a collection, it should not have any children. + # No need to go further. + if self.asset_type != ASSET_TYPE_COLLECTION: + return + + obj_languages = self.summary.get('languages', []) + languages = set() + + if children: + languages = set(obj_languages) + children_languages = [child.summary.get('languages') + for child in children + if child.summary.get('languages')] + else: + children_languages = list(self.children + .values_list('summary__languages', + flat=True) + .exclude(Q(summary__languages=[]) | + Q(summary__languages=[None])) + .order_by()) + + if children_languages: + # Flatten `children_languages` to 1-dimension list. + languages.update(reduce(add, children_languages)) + + languages.discard(None) + # Object of type set is not JSON serializable + languages = list(languages) + + # If languages are still the same, no needs to update the object + if sorted(obj_languages) == sorted(languages): + return + + self.summary['languages'] = languages + self.save(update_fields=['summary']) + @property def version__content_hash(self): - # Avoid reading the propery `self.latest_version` more than once, since + # Avoid reading the property `self.latest_version` more than once, since # it may execute a database query each time it's read latest_version = self.latest_version if latest_version: @@ -922,12 +1092,24 @@ def version__content_hash(self): @property def version_id(self): - # Avoid reading the propery `self.latest_version` more than once, since + # Avoid reading the property `self.latest_version` more than once, since # it may execute a database query each time it's read latest_version = self.latest_version if latest_version: return latest_version.uid + @property + def version_number_and_date(self) -> str: + # Returns the count of all deployed versions (plus one for the current + # version if it is not deployed) and the date the asset was last + # modified + count = self.deployed_versions.count() + + if not self.latest_version.deployed: + count = count + 1 + + return f'{count} {self.date_modified:(%Y-%m-%d %H:%M:%S)}' + def _populate_report_styles(self): default = self.report_styles.get(DEFAULT_REPORTS_KEY, {}) specifieds = self.report_styles.get(SPECIFIC_REPORTS_KEY, {}) @@ -994,7 +1176,6 @@ def _update_partial_permissions(self, user_id, perm, remove=False, partial_perms=None): """ Updates partial permissions relation table according to `perm`. - If `perm` == `PERM_PARTIAL_SUBMISSIONS`, then If `partial_perms` is not `None`, it should be a dict with filters mapped to their corresponding permission. @@ -1012,7 +1193,6 @@ def _update_partial_permissions(self, user_id, perm, remove=False, }], } ``` - Even if we can only restrict an user to view another's submissions so far, this code wants to be future-proof and supports other permissions such as: - `change_submissions` @@ -1033,7 +1213,6 @@ def _update_partial_permissions(self, user_id, perm, remove=False, }], } ``` - :param user_id: int. :param perm: str. see Asset.ASSIGNABLE_PERMISSIONS :param remove: boolean. Default is false. @@ -1088,7 +1267,6 @@ class AssetSnapshot(models.Model, XlsExportable, FormpackXLSFormUtils): """ This model serves as a cache of the XML that was exported by the installed version of pyxform. - TODO: come up with a policy to clear this cache out. DO NOT: depend on these snapshots existing for more than a day until a policy is set. @@ -1096,8 +1274,8 @@ class AssetSnapshot(models.Model, XlsExportable, FormpackXLSFormUtils): Remove above lines when PR is merged """ xml = models.TextField() - source = JSONField(default=dict) - details = JSONField(default=dict) + source = JSONBField(default=dict) + details = JSONBField(default=dict) owner = models.ForeignKey('auth.User', related_name='asset_snapshots', null=True, on_delete=models.CASCADE) asset = models.ForeignKey(Asset, null=True, on_delete=models.CASCADE) @@ -1197,3 +1375,14 @@ def generate_xml_from_source(self, 'warnings': warnings, }) return xml, details + + +class UserAssetSubscription(models.Model): + """ Record a user's subscription to a publicly-discoverable collection, + i.e. one where the anonymous user has been granted `discover_asset` """ + asset = models.ForeignKey(Asset, on_delete=models.CASCADE) + user = models.ForeignKey('auth.User', on_delete=models.CASCADE) + uid = KpiUidField(uid_prefix='b') + + class Meta: + unique_together = ('asset', 'user') \ No newline at end of file diff --git a/kpi/models/asset_user_supervisor_permissions.py b/kpi/models/asset_user_supervisor_permissions.py new file mode 100644 index 0000000000..eef145ca32 --- /dev/null +++ b/kpi/models/asset_user_supervisor_permissions.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +from django.utils import timezone +from django.db import models +from jsonbfield.fields import JSONField as JSONBField + + +class AssetUserSupervisorPermissions(models.Model): + """ + Many-to-Many table which provides users' permissions + on other users's submissions + + For example, + - Asset: + - uid: aAAAAAA + - id: 1 + - User: + - username: someuser + - id: 1 + We want someuser to be able to view otheruser's submissions + Records should be + `permissions` is dict formatted as is: + asset_id | user_id | permissions + 1 | 1 | {"someuser": ["view_submissions"]} + + Using a list per user for permissions, gives the opportunity to add other permissions + such as `change_submissions`, `delete_submissions` for later purpose + """ + asset = models.ForeignKey('Asset', related_name='asset_supervisor_permissions', on_delete=models.CASCADE) + user = models.ForeignKey('auth.User', related_name='user_supervisor_permissions', on_delete=models.CASCADE) + permissions = JSONBField(default=dict) + date_created = models.DateTimeField(default=timezone.now) + date_modified = models.DateTimeField(default=timezone.now) + + def save(self, *args, **kwargs): + + if self.pk is not None: + self.date_modified = timezone.now() + + super(AssetUserSupervisorPermissions, self).save(*args, **kwargs) From 340873bac5e06652d572db01f0c59b2dc860c98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 24 Apr 2019 13:07:24 -0400 Subject: [PATCH 008/499] Handle multiple permissions for each method in SubmissionsPermissions class --- kpi/permissions.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ kpi/views.py | 9 ++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/kpi/permissions.py b/kpi/permissions.py index 30fbac7c18..9155fa0ec9 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -224,8 +224,18 @@ class CollectionNestedObjectPermission(BaseCollectionNestedObjectPermission, """ perms_map = { +<<<<<<< HEAD 'GET': ['%(app_label)s.view_collection'], 'POST': ['%(app_label)s.change_collection'], +======= + 'GET': ['%(app_label)s.view_%(model_name)s', + '%(app_label)s.supervisor_view_%(model_name)s'], + 'OPTIONS': ['%(app_label)s.view_%(model_name)s'], + 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.change_%(model_name)s'], +>>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class } perms_map['OPTIONS'] = perms_map['GET'] @@ -245,11 +255,34 @@ class IsOwnerOrReadOnly(permissions.DjangoObjectPermissions): # With the default of True, anonymous requests are categorically rejected. authenticated_users_only = False +<<<<<<< HEAD perms_map = permissions.DjangoObjectPermissions.perms_map perms_map['GET'] = ['%(app_label)s.view_%(model_name)s'] perms_map['OPTIONS'] = perms_map['GET'] perms_map['HEAD'] = perms_map['GET'] +======= + asset_uid = self._get_parents_query_dict(request).get("asset") + asset = get_object_or_404(Asset, uid=asset_uid) + required_permissions = self.get_required_permissions(request.method, view.action) + + has_perm = False + for permission in required_permissions: + if asset.has_perm(request.user, permission): + has_perm = True + break + + # We don't want to make a difference between non-existing assets vs non permitted assets + # to avoid users to be able guess asset existence + if not has_perm: + # Except if users are allowed to view submissions, we want to show them Access Denied + # @ TODO handle supervisor permissions + if request.method not in permissions.SAFE_METHODS: + view_permissions = self.get_required_permissions("GET") + can_view = asset.has_perm(request.user, view_permissions[0]) + if can_view: + return False +>>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class class PostMappedToChangePermission(IsOwnerOrReadOnly): """ @@ -260,6 +293,7 @@ class PostMappedToChangePermission(IsOwnerOrReadOnly): perms_map['POST'] = ['%(app_label)s.change_%(model_name)s'] +<<<<<<< HEAD class SubmissionPermission(AssetNestedObjectPermission): """ Permissions for submissions. @@ -277,6 +311,9 @@ class SubmissionPermission(AssetNestedObjectPermission): } def _get_user_permissions(self, asset, user): +======= + def get_required_permissions(self, method, action=None): +>>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class """ Overrides parent method to include partial permissions (which are specific to submissions) @@ -300,6 +337,7 @@ def _get_user_permissions(self, asset, user): return user_permissions +<<<<<<< HEAD class EditSubmissionPermission(SubmissionPermission): perms_map = { @@ -313,3 +351,15 @@ class SubmissionValidationStatusPermission(SubmissionPermission): 'PATCH': ['%(app_label)s.validate_%(model_name)s'], 'DELETE': ['%(app_label)s.validate_%(model_name)s'], } +======= + # Handle + if action in self.action_map and self.action_map.get(action).get(method): + perms = [perm % kwargs for perm in self.action_map.get(action).get(method)] + else: + if method not in self.perms_map: + raise exceptions.MethodNotAllowed(method) + + perms = [perm % kwargs for perm in self.perms_map[method]] + + return [perm.replace("{}.".format(app_label), "") for perm in perms] +>>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class diff --git a/kpi/views.py b/kpi/views.py index ef6b1df9dd..9e9e430208 100644 --- a/kpi/views.py +++ b/kpi/views.py @@ -946,10 +946,13 @@ class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): permission_classes = (SubmissionsPermissions,) def _get_asset(self): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - return asset + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset def _get_deployment(self): """ From cd0e61d5f71f433444c78b7775ac659ea6887264 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 24 Apr 2019 13:11:01 -0400 Subject: [PATCH 009/499] Refactor Permissions classes - Use singular instead of plural --- kobo/apps/hook/views/v2/hook_log.py | 10 ++++++ kpi/permissions.py | 48 +++++++++++++++++++++++++++++ kpi/views.py | 4 +-- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/kobo/apps/hook/views/v2/hook_log.py b/kobo/apps/hook/views/v2/hook_log.py index 106a432b51..cdba224ece 100644 --- a/kobo/apps/hook/views/v2/hook_log.py +++ b/kobo/apps/hook/views/v2/hook_log.py @@ -5,12 +5,22 @@ from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin +<<<<<<< HEAD:kobo/apps/hook/views/v2/hook_log.py from kobo.apps.hook.constants import KOBO_INTERNAL_ERROR_STATUS_CODE from kobo.apps.hook.models.hook_log import HookLog from kobo.apps.hook.serializers.v2.hook_log import HookLogSerializer from kpi.paginators import TinyPaginated from kpi.permissions import AssetEditorSubmissionViewerPermission from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin +======= +from ..constants import KOBO_INTERNAL_ERROR_STATUS_CODE +from ..models.hook_log import HookLog +from ..serializers.hook_log import HookLogSerializer +from kpi.models import Asset +from kpi.permissions import AssetOwnerNestedObjectPermission +from kpi.serializers import TinyPaginated +from kpi.views import AssetOwnerFilterBackend, SubmissionViewSet +>>>>>>> Refactor Permissions classes - Use singular instead of plural:kobo/apps/hook/views/hook_log.py class HookLogViewSet(AssetNestedObjectViewsetMixin, diff --git a/kpi/permissions.py b/kpi/permissions.py index 9155fa0ec9..e44f9f14ee 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -39,9 +39,32 @@ class AbstractParentObjectNestedObjectPermission(permissions.BasePermission): Common methods are property are defined within this class. """ +<<<<<<< HEAD @property def perms_map(self): raise NotImplementedError +======= + # Setting this to False allows real permission checking on AnonymousUser. + # With the default of True, anonymous requests are categorically rejected. + authenticated_users_only = False + + perms_map = permissions.DjangoObjectPermissions.perms_map + perms_map['GET'] = ['%(app_label)s.view_%(model_name)s'] + perms_map['OPTIONS'] = perms_map['GET'] + perms_map['HEAD'] = perms_map['GET'] + + +class PostMappedToChangePermission(IsOwnerOrReadOnly): + ''' + Maps POST requests to the change_model permission instead of DRF's default + of add_model + ''' + perms_map = IsOwnerOrReadOnly.perms_map + perms_map['POST'] = ['%(app_label)s.change_%(model_name)s'] + + +class AssetNestedObjectPermission(permissions.BasePermission): +>>>>>>> Refactor Permissions classes - Use singular instead of plural def has_permission(self, request, view): raise NotImplementedError @@ -82,6 +105,7 @@ def get_required_permissions(self, method): :param method: str. e.g. Mostly keys of `perms_map` :return: """ +<<<<<<< HEAD app_label = self.APP_LABEL kwargs = { @@ -121,6 +145,26 @@ def _get_asset(view): class BaseCollectionNestedObjectPermission(AbstractParentObjectNestedObjectPermission): +======= + result = {} + for kwarg_name, kwarg_value in request.parser_context.get("kwargs").items(): + if kwarg_name.startswith(extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX): + query_lookup = kwarg_name.replace( + extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, + '', + 1 + ) + query_value = kwarg_value + result[query_lookup] = query_value + return result + + +class AssetOwnerNestedObjectPermission(AssetNestedObjectPermission): + """ + Permissions for objects that are nested under Asset which only owner should access. + Others should receive a 404 response (instead of 403) to avoid revealing existence + of objects. +>>>>>>> Refactor Permissions classes - Use singular instead of plural """ Base class for Collection and related objects permissions """ @@ -216,8 +260,12 @@ class AssetEditorSubmissionViewerPermission(AssetNestedObjectPermission): } +<<<<<<< HEAD class CollectionNestedObjectPermission(BaseCollectionNestedObjectPermission, AssetNestedObjectPermission): +======= +class SubmissionPermission(AssetNestedObjectPermission): +>>>>>>> Refactor Permissions classes - Use singular instead of plural """ Permissions for nested objects of Collection. Users need `*_collection` permissions to operate on these objects diff --git a/kpi/views.py b/kpi/views.py index 9e9e430208..d443dbb043 100644 --- a/kpi/views.py +++ b/kpi/views.py @@ -85,7 +85,7 @@ IsOwnerOrReadOnly, PostMappedToChangePermission, get_perm_name, - SubmissionsPermissions + SubmissionPermission ) from .renderers import ( AssetJsonRenderer, @@ -943,7 +943,7 @@ class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): renderers.JSONRenderer, SubmissionXMLRenderer ) - permission_classes = (SubmissionsPermissions,) + permission_classes = (SubmissionPermission,) def _get_asset(self): From b128480d1d3ed198f0a00ee042eed1e1927b3cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 24 Apr 2019 15:37:59 -0400 Subject: [PATCH 010/499] Refactored permissions variables, use variables from 'constant.py' instead of hardcoded string --- .../asset_user_supervisor_permission.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 kpi/models/asset_user_supervisor_permission.py diff --git a/kpi/models/asset_user_supervisor_permission.py b/kpi/models/asset_user_supervisor_permission.py new file mode 100644 index 0000000000..07ae0bae4c --- /dev/null +++ b/kpi/models/asset_user_supervisor_permission.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +from django.utils import timezone +from django.db import models +from jsonbfield.fields import JSONField as JSONBField + + +class AssetUserSupervisorPermission(models.Model): + """ + Many-to-Many table which provides users' permissions + on other users's submissions + + For example, + - Asset: + - uid: aAAAAAA + - id: 1 + - User: + - username: someuser + - id: 1 + We want someuser to be able to view otheruser's submissions + Records should be + `permissions` is dict formatted as is: + asset_id | user_id | permissions + 1 | 1 | {"someuser": ["view_submissions"]} + + Using a list per user for permissions, gives the opportunity to add other permissions + such as `change_submissions`, `delete_submissions` for later purpose + """ + asset = models.ForeignKey('Asset', related_name='asset_supervisor_permissions', on_delete=models.CASCADE) + user = models.ForeignKey('auth.User', related_name='user_supervisor_permissions', on_delete=models.CASCADE) + permissions = JSONBField(default=dict) + date_created = models.DateTimeField(default=timezone.now) + date_modified = models.DateTimeField(default=timezone.now) + + def save(self, *args, **kwargs): + + if self.pk is not None: + self.date_modified = timezone.now() + + super(AssetUserSupervisorPermissions, self).save(*args, **kwargs) From a20d2d6e73b101ad1a462636cfd64236e63c44a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 24 Apr 2019 15:40:32 -0400 Subject: [PATCH 011/499] Refactored permissions variables, use variables from 'constant.py' instead of hardcoded string --- kobo/apps/reports/views.py | 15 +++++++ .../commands/add_can_validate_permissions.py | 1 + .../commands/sync_kobocat_xforms.py | 4 +- ...ate_many_to_many_asset_user_permissions.py | 6 +-- .../asset_user_supervisor_permissions.py | 40 ------------------- kpi/serializers.py | 8 ++-- kpi/tests/api/v2/test_api_assets.py | 1 + kpi/tests/test_permissions.py | 9 +++-- kpi/views.py | 35 ++++++++++++---- 9 files changed, 58 insertions(+), 61 deletions(-) delete mode 100644 kpi/models/asset_user_supervisor_permissions.py diff --git a/kobo/apps/reports/views.py b/kobo/apps/reports/views.py index 0b8237047b..ec5c606277 100644 --- a/kobo/apps/reports/views.py +++ b/kobo/apps/reports/views.py @@ -9,8 +9,12 @@ ) from kpi.models import Asset from kpi.models.object_permission import get_objects_for_user, get_anonymous_user +<<<<<<< HEAD from .serializers import ReportsListSerializer, ReportsDetailSerializer from kpi.constants import PERM_VIEW_SUBMISSIONS, PERM_PARTIAL_SUBMISSIONS +======= +from kpi.constants import PERM_VIEW_SUBMISSIONS +>>>>>>> Refactored permissions variables, use variables from 'constant.py' instead of hardcoded string class ReportsViewSet(mixins.ListModelMixin, @@ -46,6 +50,7 @@ def get_object(self): def get_queryset(self): +<<<<<<< HEAD queryset = Asset.objects.filter(asset_type=ASSET_TYPE_SURVEY) if self.action == 'retrieve': # `get_object()` will do the checking; no need to manipulate the @@ -79,3 +84,13 @@ def get_queryset(self): ) & Asset.objects.deployed() return deployed_assets +======= + + # Retrieve all deployed assets first. + deployed_assets = Asset.objects.filter(asset_versions__deployed=True).distinct() + # Then retrieve all assets user is allowed to view (user must have `PERM_VIEW_SUBMISSIONS` on Asset objects) + user_assets = get_objects_for_user(self.request.user, PERM_VIEW_SUBMISSIONS, deployed_assets) + publicly_shared_assets = get_objects_for_user(get_anonymous_user(), PERM_VIEW_SUBMISSIONS, deployed_assets) + + return user_assets | publicly_shared_assets +>>>>>>> Refactored permissions variables, use variables from 'constant.py' instead of hardcoded string diff --git a/kpi/management/commands/add_can_validate_permissions.py b/kpi/management/commands/add_can_validate_permissions.py index b8ec40a4ee..cae5b5e81d 100644 --- a/kpi/management/commands/add_can_validate_permissions.py +++ b/kpi/management/commands/add_can_validate_permissions.py @@ -5,6 +5,7 @@ from kpi.constants import PERM_VALIDATE_SUBMISSIONS from ...models import Asset +from kpi.constants import PERM_VALIDATE_SUBMISSIONS class Command(BaseCommand): diff --git a/kpi/management/commands/sync_kobocat_xforms.py b/kpi/management/commands/sync_kobocat_xforms.py index 28d62f3cf3..09838f1d8d 100644 --- a/kpi/management/commands/sync_kobocat_xforms.py +++ b/kpi/management/commands/sync_kobocat_xforms.py @@ -411,8 +411,8 @@ def _sync_permissions(asset, xform): # This user's KPI access came only from this script, and now all KC # permissions have been removed. Purge all KPI grant permissions, # even the non-mapped ones, in order to clean up prerequisite - # permissions (e.g. 'view_asset' is a prerequisite of - # 'view_submissions') + # permissions (e.g. `PERM_VIEW_ASSET` is a prerequisite of + # `PERM_VIEW_SUBMISSIONS`) ObjectPermission.objects.filter( user_id=user, deny=False, diff --git a/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py b/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py index fcecf00097..24173d75d8 100644 --- a/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py +++ b/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='AssetUserSupervisorPermissions', + name='AssetUserSupervisorPermission', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('permissions', jsonbfield.fields.JSONField(default=dict)), @@ -30,12 +30,12 @@ class Migration(migrations.Migration): ], ), migrations.AddField( - model_name='assetusersupervisorpermissions', + model_name='assetusersupervisorpermission', name='asset', field=models.ForeignKey(related_name='asset_supervisor_permissions', to='kpi.Asset'), ), migrations.AddField( - model_name='assetusersupervisorpermissions', + model_name='assetusersupervisorpermission', name='user', field=models.ForeignKey(related_name='user_supervisor_permissions', to=settings.AUTH_USER_MODEL), ), diff --git a/kpi/models/asset_user_supervisor_permissions.py b/kpi/models/asset_user_supervisor_permissions.py deleted file mode 100644 index eef145ca32..0000000000 --- a/kpi/models/asset_user_supervisor_permissions.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- - -from django.utils import timezone -from django.db import models -from jsonbfield.fields import JSONField as JSONBField - - -class AssetUserSupervisorPermissions(models.Model): - """ - Many-to-Many table which provides users' permissions - on other users's submissions - - For example, - - Asset: - - uid: aAAAAAA - - id: 1 - - User: - - username: someuser - - id: 1 - We want someuser to be able to view otheruser's submissions - Records should be - `permissions` is dict formatted as is: - asset_id | user_id | permissions - 1 | 1 | {"someuser": ["view_submissions"]} - - Using a list per user for permissions, gives the opportunity to add other permissions - such as `change_submissions`, `delete_submissions` for later purpose - """ - asset = models.ForeignKey('Asset', related_name='asset_supervisor_permissions', on_delete=models.CASCADE) - user = models.ForeignKey('auth.User', related_name='user_supervisor_permissions', on_delete=models.CASCADE) - permissions = JSONBField(default=dict) - date_created = models.DateTimeField(default=timezone.now) - date_modified = models.DateTimeField(default=timezone.now) - - def save(self, *args, **kwargs): - - if self.pk is not None: - self.date_modified = timezone.now() - - super(AssetUserSupervisorPermissions, self).save(*args, **kwargs) diff --git a/kpi/serializers.py b/kpi/serializers.py index dd90e9244c..bc7ff6a1b0 100644 --- a/kpi/serializers.py +++ b/kpi/serializers.py @@ -270,7 +270,7 @@ def create(self, validated_data): # permission; clear the `from_kc_only` flag ObjectPermission.objects.filter( user=user, - permission__codename='from_kc_only', + permission__codename=PERM_FROM_KC_ONLY, object_id=content_object.id, content_type=ContentType.objects.get_for_model(content_object) ).delete() @@ -361,14 +361,14 @@ def create(self, validated_data): # TODO: Move to a validator? if asset and source: - if not self.context['request'].user.has_perm('view_asset', asset): + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): # The client is not allowed to snapshot this asset raise exceptions.PermissionDenied validated_data['source'] = source snapshot = AssetSnapshot.objects.create(**validated_data) elif asset: # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm('view_asset', asset): + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): # The client is not allowed to snapshot this asset raise exceptions.PermissionDenied # asset.snapshot pulls , by default, a snapshot for the latest @@ -1240,7 +1240,7 @@ def __init__(self, *args, **kwargs): *args, **kwargs) self.fields['collection'].queryset = get_objects_for_user( get_anonymous_user(), - 'view_collection', + PERM_VIEW_COLLECTION, Collection.objects.filter(discoverable_when_public=True) ) diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index f40d6521e6..f36860eafe 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -15,6 +15,7 @@ PERM_PARTIAL_SUBMISSIONS, ) +from kpi.constants import PERM_CHANGE_ASSET, PERM_VIEW_ASSET from kpi.models import Asset from kpi.models import AssetFile from kpi.models import AssetVersion diff --git a/kpi/tests/test_permissions.py b/kpi/tests/test_permissions.py index 916b638867..432822c333 100644 --- a/kpi/tests/test_permissions.py +++ b/kpi/tests/test_permissions.py @@ -11,6 +11,11 @@ from ..models.asset import Asset from ..models.collection import Collection from ..models.object_permission import get_all_objects_for_user +from kpi.constants import PERM_VIEW_ASSET, PERM_CHANGE_ASSET, PERM_ADD_SUBMISSIONS, \ + PERM_VIEW_SUBMISSIONS, PERM_SUPERVISOR_VIEW_SUBMISSION, \ + PERM_CHANGE_SUBMISSIONS, PERM_VALIDATE_SUBMISSIONS, PERM_SHARE_ASSET, \ + PERM_DELETE_ASSET, PERM_SHARE_SUBMISSIONS, PERM_DELETE_SUBMISSIONS, \ + PERM_VIEW_COLLECTION, PERM_CHANGE_COLLECTION class BasePermissionsTestCase(TestCase): @@ -68,11 +73,7 @@ def _test_remove_perm(self, obj, perm_name_prefix, user): :type perm_name_prefix: str :param user: The user for whom permissions on `obj` will be manipulated. :type user: :py:class:`User` -<<<<<<< HEAD """ -======= - ''' ->>>>>>> PEP-8 fixes perm_name = self._get_perm_name(perm_name_prefix, obj) self.assertTrue(user.has_perm(perm_name, obj)) obj.remove_perm(user, perm_name) diff --git a/kpi/views.py b/kpi/views.py index d443dbb043..54bf9fff22 100644 --- a/kpi/views.py +++ b/kpi/views.py @@ -177,7 +177,7 @@ def _requesting_user_can_share(self, affected_object, codename): """ model_name = affected_object._meta.model_name if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = 'share_submissions' + share_permission = PERM_SHARE_SUBMISSIONS else: share_permission = 'share_{}'.format(model_name) return affected_object.has_perm(self.request.user, share_permission) @@ -330,9 +330,9 @@ def _get_tags_on_items(content_type_name, avail_items): values_list('id', flat=True) accessible_collections = get_objects_for_user( - user, 'view_collection', Collection).only('pk') + user, PERM_VIEW_COLLECTION, Collection).only('pk') accessible_assets = get_objects_for_user( - user, 'view_asset', Asset).only('pk') + user, PERM_VIEW_ASSET, Asset).only('pk') all_tag_ids = list(chain( _get_tags_on_items('collection', accessible_collections), _get_tags_on_items('asset', accessible_assets), @@ -702,7 +702,7 @@ def get_queryset(self): def perform_create(self, serializer): asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm('change_asset', asset): + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): raise exceptions.PermissionDenied() serializer.save( asset=asset, @@ -711,7 +711,7 @@ def perform_create(self, serializer): def perform_destroy(self, *args, **kwargs): asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm('change_asset', asset): + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): raise exceptions.PermissionDenied() return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) @@ -720,7 +720,7 @@ class PrivateContentView(PrivateStorageDetailView): model_file_field = 'content' def can_access_file(self, private_file): return private_file.request.user.has_perm( - 'view_asset', private_file.parent_object.asset) + PERM_VIEW_ASSET, private_file.parent_object.asset) @detail_route(methods=['get']) def content(self, *args, **kwargs): @@ -1022,6 +1022,25 @@ def validation_statuses(self, request, *args, **kwargs): return Response(**json_response) + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backend` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + if asset.has_perm(request.user, "supervisor_view_submissions"): + pass + + return filters + class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): model = AssetVersion @@ -1504,8 +1523,8 @@ def permissions(self, request, uid): response = {} http_status = status.HTTP_204_NO_CONTENT - if user.has_perm('share_asset', target_asset) and \ - user.has_perm('view_asset', source_asset): + if user.has_perm(PERM_SHARE_ASSET, target_asset) and \ + user.has_perm(PERM_VIEW_ASSET, source_asset): if not target_asset.copy_permissions_from(source_asset): http_status = status.HTTP_400_BAD_REQUEST response = {"detail": "Source and destination objects don't seem to have the same type"} From eb447c06212fe48a95ec3d9962d654db76488889 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 24 Apr 2019 16:06:49 -0400 Subject: [PATCH 012/499] Removed KC db connection when running tests --- kobo/apps/hook/views/v2/hook_log.py | 10 ---------- kpi/signals.py | 9 +++++++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/kobo/apps/hook/views/v2/hook_log.py b/kobo/apps/hook/views/v2/hook_log.py index cdba224ece..106a432b51 100644 --- a/kobo/apps/hook/views/v2/hook_log.py +++ b/kobo/apps/hook/views/v2/hook_log.py @@ -5,22 +5,12 @@ from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin -<<<<<<< HEAD:kobo/apps/hook/views/v2/hook_log.py from kobo.apps.hook.constants import KOBO_INTERNAL_ERROR_STATUS_CODE from kobo.apps.hook.models.hook_log import HookLog from kobo.apps.hook.serializers.v2.hook_log import HookLogSerializer from kpi.paginators import TinyPaginated from kpi.permissions import AssetEditorSubmissionViewerPermission from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin -======= -from ..constants import KOBO_INTERNAL_ERROR_STATUS_CODE -from ..models.hook_log import HookLog -from ..serializers.hook_log import HookLogSerializer -from kpi.models import Asset -from kpi.permissions import AssetOwnerNestedObjectPermission -from kpi.serializers import TinyPaginated -from kpi.views import AssetOwnerFilterBackend, SubmissionViewSet ->>>>>>> Refactor Permissions classes - Use singular instead of plural:kobo/apps/hook/views/hook_log.py class HookLogViewSet(AssetNestedObjectViewsetMixin, diff --git a/kpi/signals.py b/kpi/signals.py index 835df2d6bd..f38b87e09a 100644 --- a/kpi/signals.py +++ b/kpi/signals.py @@ -1,7 +1,12 @@ +<<<<<<< HEAD # coding: utf-8 from django.conf import settings from django.contrib.auth.models import User +======= +# -*- coding: utf-8 -*- +from django.conf import settings +>>>>>>> Removed KC db connection when running tests from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from rest_framework.authtoken.models import Token @@ -44,6 +49,7 @@ def save_kobocat_user(sender, instance, created, raw, **kwargs): `settings.KOBOCAT_DEFAULT_PERMISSION_CONTENT_TYPES` """ if not settings.TESTING: +<<<<<<< HEAD KobocatUser.sync(instance) if created: @@ -54,6 +60,9 @@ def save_kobocat_user(sender, instance, created, raw, **kwargs): # seem to help. We should roll back the KC user creation if # assigning model-level permissions fails grant_kc_model_level_perms(instance) +======= + KCUser.sync(instance) +>>>>>>> Removed KC db connection when running tests @receiver(post_save, sender=Token) From beeb126dca58d4a615aa9a4dfbe2870981cd5648 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 25 Apr 2019 09:20:35 -0400 Subject: [PATCH 013/499] PEP-8 compliance --- kpi/models/asset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 5037fb910a..87a1b60763 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -139,6 +139,7 @@ def add(self, *tags, **kwargs): super().add(*tags_out, **kwargs) + FLATTEN_OPTS = { 'remove_columns': { 'survey': [ From ef4830c7ebf5c5f5e468f37f2261cf10d40709b6 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 25 Apr 2019 15:21:18 -0400 Subject: [PATCH 014/499] Renamed 'supervisor_*' to 'restricted_*' --- ...ate_many_to_many_asset_user_permissions.py | 6 +-- kpi/migrations/0023_restricted_permissions.py | 42 +++++++++++++++++++ ...py => asset_user_restricted_permission.py} | 4 +- kpi/tests/test_permissions.py | 1 + 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 kpi/migrations/0023_restricted_permissions.py rename kpi/models/{asset_user_supervisor_permission.py => asset_user_restricted_permission.py} (94%) diff --git a/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py b/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py index 24173d75d8..7c3184101a 100644 --- a/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py +++ b/kpi/migrations/0023_create_many_to_many_asset_user_permissions.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='AssetUserSupervisorPermission', + name='AssetUserRestrictedPermission', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('permissions', jsonbfield.fields.JSONField(default=dict)), @@ -30,12 +30,12 @@ class Migration(migrations.Migration): ], ), migrations.AddField( - model_name='assetusersupervisorpermission', + model_name='assetuserrestrictedpermission', name='asset', field=models.ForeignKey(related_name='asset_supervisor_permissions', to='kpi.Asset'), ), migrations.AddField( - model_name='assetusersupervisorpermission', + model_name='assetuserrestrictedpermission', name='user', field=models.ForeignKey(related_name='user_supervisor_permissions', to=settings.AUTH_USER_MODEL), ), diff --git a/kpi/migrations/0023_restricted_permissions.py b/kpi/migrations/0023_restricted_permissions.py new file mode 100644 index 0000000000..7c3184101a --- /dev/null +++ b/kpi/migrations/0023_restricted_permissions.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields +import kpi.models.asset_file +import private_storage.storage.s3boto3 +from django.conf import settings +import django.utils.timezone +import private_storage.fields +import kpi.models.import_export_task +import jsonbfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('kpi', '0022_assetfile'), + ] + + operations = [ + migrations.CreateModel( + name='AssetUserRestrictedPermission', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('permissions', jsonbfield.fields.JSONField(default=dict)), + ('date_created', models.DateTimeField(default=django.utils.timezone.now)), + ('date_modified', models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + migrations.AddField( + model_name='assetuserrestrictedpermission', + name='asset', + field=models.ForeignKey(related_name='asset_supervisor_permissions', to='kpi.Asset'), + ), + migrations.AddField( + model_name='assetuserrestrictedpermission', + name='user', + field=models.ForeignKey(related_name='user_supervisor_permissions', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/kpi/models/asset_user_supervisor_permission.py b/kpi/models/asset_user_restricted_permission.py similarity index 94% rename from kpi/models/asset_user_supervisor_permission.py rename to kpi/models/asset_user_restricted_permission.py index 07ae0bae4c..251fa5868e 100644 --- a/kpi/models/asset_user_supervisor_permission.py +++ b/kpi/models/asset_user_restricted_permission.py @@ -5,10 +5,10 @@ from jsonbfield.fields import JSONField as JSONBField -class AssetUserSupervisorPermission(models.Model): +class AssetUserRestrictedPermission(models.Model): """ Many-to-Many table which provides users' permissions - on other users's submissions + on other users' submissions For example, - Asset: diff --git a/kpi/tests/test_permissions.py b/kpi/tests/test_permissions.py index 432822c333..7de896acab 100644 --- a/kpi/tests/test_permissions.py +++ b/kpi/tests/test_permissions.py @@ -391,6 +391,7 @@ def test_contradict_implied_asset_deny_permissions(self): (PERM_ADD_SUBMISSIONS, True), (PERM_CHANGE_ASSET, True), (PERM_CHANGE_SUBMISSIONS, False), + (PERM_RESTRICTED_VIEW_SUBMISSIONS, False), (PERM_VALIDATE_SUBMISSIONS, True), (PERM_VIEW_ASSET, False), (PERM_VIEW_SUBMISSIONS, False) From 771aa6da84af633f7ae6cfce940f403f5c5cbec0 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 25 Apr 2019 18:30:11 -0400 Subject: [PATCH 015/499] Removed contradictory permissions on assignment --- kpi/models/asset.py | 6 ++++++ kpi/models/object_permission.py | 1 - kpi/tests/test_permissions.py | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 87a1b60763..2da9efda9b 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -674,6 +674,12 @@ def __init__(self, *args, **kwargs): def __str__(self): return '{} ({})'.format(self.name, self.uid) + def assign_perm( + self, user_obj, perm, deny=False, defer_recalc=False, + skip_kc=False + ): + return super(Asset, self).assign_perm(user_obj, perm, deny, defer_recalc, skip_kc) + def adjust_content_on_save(self): """ This is called on save by default if content exists. diff --git a/kpi/models/object_permission.py b/kpi/models/object_permission.py index dd00eafe8a..2d9f75c040 100644 --- a/kpi/models/object_permission.py +++ b/kpi/models/object_permission.py @@ -3,7 +3,6 @@ import re from collections import defaultdict - from django.apps import apps from django.conf import settings from django.contrib.auth.models import User, AnonymousUser, Permission diff --git a/kpi/tests/test_permissions.py b/kpi/tests/test_permissions.py index 7de896acab..432822c333 100644 --- a/kpi/tests/test_permissions.py +++ b/kpi/tests/test_permissions.py @@ -391,7 +391,6 @@ def test_contradict_implied_asset_deny_permissions(self): (PERM_ADD_SUBMISSIONS, True), (PERM_CHANGE_ASSET, True), (PERM_CHANGE_SUBMISSIONS, False), - (PERM_RESTRICTED_VIEW_SUBMISSIONS, False), (PERM_VALIDATE_SUBMISSIONS, True), (PERM_VIEW_ASSET, False), (PERM_VIEW_SUBMISSIONS, False) From d0c594b0a39eff292eb47d0b6c7ae8066f286edf Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 25 Apr 2019 18:47:28 -0400 Subject: [PATCH 016/499] Added redis to travisCI (for session) --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index d92aa8cbb7..7935e72ce9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -45,7 +45,11 @@ env: DJANGO_SETTINGS_MODULE=kobo.settings.testing DJANGO_LANGUAGE_CODES="en ar es fr hi ku pl pt zh-hans" DATABASE_URL="postgres://postgres@localhost:5432/travis_ci_test" +<<<<<<< HEAD REDIS_SESSION_URL="redis://localhost:6379" +======= + REDIS_SESSION_URL="redis://localhost:6389" +>>>>>>> Added redis to travisCI (for session) TRAVIS_NODE_VERSION="8" PATH=$PATH:$HOME/build/kobotoolbox/kpi/node_modules/.bin/ install: From affb6f295bcdba44ac8e3cacedc91cf1b277fbb2 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 25 Apr 2019 18:58:49 -0400 Subject: [PATCH 017/499] Fixed bad port in TravisCI config --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index 7935e72ce9..1549f434c4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -45,11 +45,15 @@ env: DJANGO_SETTINGS_MODULE=kobo.settings.testing DJANGO_LANGUAGE_CODES="en ar es fr hi ku pl pt zh-hans" DATABASE_URL="postgres://postgres@localhost:5432/travis_ci_test" +<<<<<<< HEAD <<<<<<< HEAD REDIS_SESSION_URL="redis://localhost:6379" ======= REDIS_SESSION_URL="redis://localhost:6389" >>>>>>> Added redis to travisCI (for session) +======= + REDIS_SESSION_URL="redis://localhost:6379" +>>>>>>> Fixed bad port in TravisCI config TRAVIS_NODE_VERSION="8" PATH=$PATH:$HOME/build/kobotoolbox/kpi/node_modules/.bin/ install: From 4d569678ee6d551245180c974ef20dc2530982ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Mon, 29 Apr 2019 18:14:55 -0400 Subject: [PATCH 018/499] WIP - Can assign restricted permissions to user --- kpi/models/asset.py | 22 ++-- kpi/permissions.py | 253 +++++++++++++------------------------------- 2 files changed, 91 insertions(+), 184 deletions(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 2da9efda9b..ffc02b8352 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -674,12 +674,6 @@ def __init__(self, *args, **kwargs): def __str__(self): return '{} ({})'.format(self.name, self.uid) - def assign_perm( - self, user_obj, perm, deny=False, defer_recalc=False, - skip_kc=False - ): - return super(Asset, self).assign_perm(user_obj, perm, deny, defer_recalc, skip_kc) - def adjust_content_on_save(self): """ This is called on save by default if content exists. @@ -847,6 +841,22 @@ def get_partial_perms( else: return list(perms) + def get_usernames_for_restricted_perm(self, user_obj, perm=PERM_VIEW_SUBMISSIONS): + """ + Returns the list of usernames for a specfic permission `perm` + and this specific asset. + :param user_obj: auth.User + :param perm: see `constants.*_SUBMISSIONS` + :return: + """ + if not (perm.endswith(SUFFIX_SUBMISSIONS_PERMS) and + not perm == PERM_RESTRICTED_SUBMISSIONS): + raise BadPermissionsException("Only global permissions for " + "submissions are supported.") + + perms = self.get_restricted_perms(user_obj, True) + if perms: + return perms.get(perm) return None @property diff --git a/kpi/permissions.py b/kpi/permissions.py index e44f9f14ee..c5a08b939b 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -3,9 +3,9 @@ from rest_framework import exceptions, permissions from kpi.models.asset import Asset -from kpi.models.collection import Collection +from kpi.models.asset_user_partial_permission import AssetUserPartialPermission from kpi.models.object_permission import get_anonymous_user -from kpi.constants import PERM_PARTIAL_SUBMISSIONS +from kpi.constants import PERM_VIEW_SUBMISSIONS, PERM_PARTIAL_SUBMISSIONS # FIXME: Move to `object_permissions` module. @@ -13,17 +13,15 @@ def get_perm_name(perm_name_prefix, model_instance): """ Get the type-specific permission name for a model from a permission name prefix and a model instance. - Example: >>>get_perm_name('view', my_asset) 'view_asset' - :param perm_name_prefix: Prefix of the desired permission name (i.e. "view_", "change_", or "delete_"). :type perm_name_prefix: str :param model_instance: An instance of the model for which the permission name is desired. - :type model_instance: :py:class:`Collection` or :py:class:`Asset` + :type model_instance: :py:class:`Asset` :return: The computed permission name. :rtype: str """ @@ -33,46 +31,24 @@ def get_perm_name(perm_name_prefix, model_instance): return perm_name -class AbstractParentObjectNestedObjectPermission(permissions.BasePermission): +class BaseAssetNestedObjectPermission(permissions.BasePermission): """ - Main abstract class for Asset/Collection and related objects permissions - Common methods are property are defined within this class. + Base class for Asset and related objects permissions """ -<<<<<<< HEAD - @property - def perms_map(self): - raise NotImplementedError -======= - # Setting this to False allows real permission checking on AnonymousUser. - # With the default of True, anonymous requests are categorically rejected. - authenticated_users_only = False - - perms_map = permissions.DjangoObjectPermissions.perms_map - perms_map['GET'] = ['%(app_label)s.view_%(model_name)s'] - perms_map['OPTIONS'] = perms_map['GET'] - perms_map['HEAD'] = perms_map['GET'] - - -class PostMappedToChangePermission(IsOwnerOrReadOnly): - ''' - Maps POST requests to the change_model permission instead of DRF's default - of add_model - ''' - perms_map = IsOwnerOrReadOnly.perms_map - perms_map['POST'] = ['%(app_label)s.change_%(model_name)s'] - - -class AssetNestedObjectPermission(permissions.BasePermission): ->>>>>>> Refactor Permissions classes - Use singular instead of plural - - def has_permission(self, request, view): - raise NotImplementedError + MODEL_NAME = Asset._meta.model_name + APP_LABEL = Asset._meta.app_label - def has_object_permission(self, request, view, obj): - # Because authentication checks have already executed via has_permission, - # always return True. - return True + @staticmethod + def _get_asset(view): + """ + Returns Asset from the view. + The view must have a property `asset`. + It's easily done with `AssetNestedObjectViewsetMixin (kpi.utils.viewset_mixins.py) + :param view: ViewSet + :return: Asset + """ + return view.asset @classmethod def _get_parent_object(cls, view): @@ -83,6 +59,7 @@ def _get_parent_object(cls, view): :param view: ViewSet :return: Asset/Collection """ + # TODO: remove all collection stuff if cls.MODEL_NAME == 'collection': return cls._get_collection(view) else: @@ -101,11 +78,9 @@ def get_required_permissions(self, method): """ Given a model and an HTTP method, return the list of permission codes that the user is required to have. - :param method: str. e.g. Mostly keys of `perms_map` :return: """ -<<<<<<< HEAD app_label = self.APP_LABEL kwargs = { @@ -123,65 +98,10 @@ def get_required_permissions(self, method): # `app_label` prefix before returning return [perm.replace("{}.".format(app_label), "") for perm in perms] - -class BaseAssetNestedObjectPermission(AbstractParentObjectNestedObjectPermission): - """ - Base class for Asset and related objects permissions - """ - - MODEL_NAME = Asset._meta.model_name - APP_LABEL = Asset._meta.app_label - - @staticmethod - def _get_asset(view): - """ - Returns Asset from the view. - The view must have a property `asset`. - It's easily done with `AssetNestedObjectViewsetMixin (kpi.utils.viewset_mixins.py) - :param view: ViewSet - :return: Asset - """ - return view.asset - - -class BaseCollectionNestedObjectPermission(AbstractParentObjectNestedObjectPermission): -======= - result = {} - for kwarg_name, kwarg_value in request.parser_context.get("kwargs").items(): - if kwarg_name.startswith(extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX): - query_lookup = kwarg_name.replace( - extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, - '', - 1 - ) - query_value = kwarg_value - result[query_lookup] = query_value - return result - - -class AssetOwnerNestedObjectPermission(AssetNestedObjectPermission): - """ - Permissions for objects that are nested under Asset which only owner should access. - Others should receive a 404 response (instead of 403) to avoid revealing existence - of objects. ->>>>>>> Refactor Permissions classes - Use singular instead of plural - """ - Base class for Collection and related objects permissions - """ - - MODEL_NAME = Collection._meta.model_name - APP_LABEL = Collection._meta.app_label - - @staticmethod - def _get_collection(view): - """ - Returns Collection from the view. - The view must have a property `collection`. - It's easily done with `CollectionNestedObjectViewsetMixin (kpi.utils.viewset_mixins.py) - :param view: ViewSet - :return: Collection - """ - return view.collection + def has_object_permission(self, request, view, obj): + # Because authentication checks have already executed via has_permission, + # always return True. + return True class AssetNestedObjectPermission(BaseAssetNestedObjectPermission): @@ -192,14 +112,14 @@ class AssetNestedObjectPermission(BaseAssetNestedObjectPermission): perms_map = { 'GET': ['%(app_label)s.view_asset'], - 'POST': ['%(app_label)s.change_asset'], + 'POST': ['%(app_label)s.manage_asset'], } perms_map['OPTIONS'] = perms_map['GET'] perms_map['HEAD'] = perms_map['GET'] perms_map['PUT'] = perms_map['POST'] perms_map['PATCH'] = perms_map['POST'] - perms_map['DELETE'] = perms_map['POST'] + perms_map['DELETE'] = perms_map['GET'] def has_permission(self, request, view): if not request.user: @@ -226,7 +146,11 @@ def has_permission(self, request, view): else: raise Http404 - has_perm = set(required_permissions).issubset(user_permissions) + if user == parent_object.owner: + # The owner can always manage permission assignments + has_perm = True + else: + has_perm = set(required_permissions).issubset(user_permissions) if has_perm: # Access granted! @@ -260,39 +184,6 @@ class AssetEditorSubmissionViewerPermission(AssetNestedObjectPermission): } -<<<<<<< HEAD -class CollectionNestedObjectPermission(BaseCollectionNestedObjectPermission, - AssetNestedObjectPermission): -======= -class SubmissionPermission(AssetNestedObjectPermission): ->>>>>>> Refactor Permissions classes - Use singular instead of plural - """ - Permissions for nested objects of Collection. - Users need `*_collection` permissions to operate on these objects - """ - - perms_map = { -<<<<<<< HEAD - 'GET': ['%(app_label)s.view_collection'], - 'POST': ['%(app_label)s.change_collection'], -======= - 'GET': ['%(app_label)s.view_%(model_name)s', - '%(app_label)s.supervisor_view_%(model_name)s'], - 'OPTIONS': ['%(app_label)s.view_%(model_name)s'], - 'HEAD': ['%(app_label)s.view_%(model_name)s'], - 'POST': ['%(app_label)s.add_%(model_name)s'], - 'PATCH': ['%(app_label)s.change_%(model_name)s'], - 'DELETE': ['%(app_label)s.change_%(model_name)s'], ->>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class - } - - perms_map['OPTIONS'] = perms_map['GET'] - perms_map['HEAD'] = perms_map['GET'] - perms_map['PUT'] = perms_map['POST'] - perms_map['PATCH'] = perms_map['POST'] - perms_map['DELETE'] = perms_map['POST'] - - # FIXME: Name is no longer accurate. class IsOwnerOrReadOnly(permissions.DjangoObjectPermissions): """ @@ -303,34 +194,11 @@ class IsOwnerOrReadOnly(permissions.DjangoObjectPermissions): # With the default of True, anonymous requests are categorically rejected. authenticated_users_only = False -<<<<<<< HEAD perms_map = permissions.DjangoObjectPermissions.perms_map perms_map['GET'] = ['%(app_label)s.view_%(model_name)s'] perms_map['OPTIONS'] = perms_map['GET'] perms_map['HEAD'] = perms_map['GET'] -======= - asset_uid = self._get_parents_query_dict(request).get("asset") - asset = get_object_or_404(Asset, uid=asset_uid) - required_permissions = self.get_required_permissions(request.method, view.action) - - has_perm = False - for permission in required_permissions: - if asset.has_perm(request.user, permission): - has_perm = True - break - - # We don't want to make a difference between non-existing assets vs non permitted assets - # to avoid users to be able guess asset existence - if not has_perm: - # Except if users are allowed to view submissions, we want to show them Access Denied - # @ TODO handle supervisor permissions - if request.method not in permissions.SAFE_METHODS: - view_permissions = self.get_required_permissions("GET") - can_view = asset.has_perm(request.user, view_permissions[0]) - if can_view: - return False ->>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class class PostMappedToChangePermission(IsOwnerOrReadOnly): """ @@ -341,7 +209,6 @@ class PostMappedToChangePermission(IsOwnerOrReadOnly): perms_map['POST'] = ['%(app_label)s.change_%(model_name)s'] -<<<<<<< HEAD class SubmissionPermission(AssetNestedObjectPermission): """ Permissions for submissions. @@ -355,17 +222,13 @@ class SubmissionPermission(AssetNestedObjectPermission): 'HEAD': ['%(app_label)s.view_%(model_name)s'], 'POST': ['%(app_label)s.add_%(model_name)s'], 'PATCH': ['%(app_label)s.change_%(model_name)s'], - 'DELETE': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], } def _get_user_permissions(self, asset, user): -======= - def get_required_permissions(self, method, action=None): ->>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class """ Overrides parent method to include partial permissions (which are specific to submissions) - :param asset: Asset :param user: auth.User :return: list @@ -385,7 +248,6 @@ def get_required_permissions(self, method, action=None): return user_permissions -<<<<<<< HEAD class EditSubmissionPermission(SubmissionPermission): perms_map = { @@ -393,21 +255,56 @@ class EditSubmissionPermission(SubmissionPermission): } +class DuplicateSubmissionPermission(SubmissionPermission): + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'POST': ['%(app_label)s.change_%(model_name)s'], + } + + class SubmissionValidationStatusPermission(SubmissionPermission): perms_map = { 'GET': ['%(app_label)s.view_%(model_name)s'], 'PATCH': ['%(app_label)s.validate_%(model_name)s'], 'DELETE': ['%(app_label)s.validate_%(model_name)s'], } -======= - # Handle - if action in self.action_map and self.action_map.get(action).get(method): - perms = [perm % kwargs for perm in self.action_map.get(action).get(method)] - else: - if method not in self.perms_map: - raise exceptions.MethodNotAllowed(method) - perms = [perm % kwargs for perm in self.perms_map[method]] - return [perm.replace("{}.".format(app_label), "") for perm in perms] ->>>>>>> Handle multiple permissions for each method in SubmissionsPermissions class +class AssetExportSettingsPermission(SubmissionPermission): + perms_map = { + 'GET': ['%(app_label)s.view_submissions'], + 'POST': ['%(app_label)s.manage_asset'], + } + + perms_map['OPTIONS'] = perms_map['GET'] + perms_map['HEAD'] = perms_map['GET'] + perms_map['PUT'] = perms_map['POST'] + perms_map['PATCH'] = perms_map['POST'] + perms_map['DELETE'] = perms_map['POST'] + +class ExportTaskPermission(SubmissionPermission): + perms_map = { + 'GET': ['%(app_label)s.view_submissions'], + } + + perms_map['POST'] = perms_map['GET'] + perms_map['DELETE'] = perms_map['GET'] + + +class ReportPermission(IsOwnerOrReadOnly): + def has_object_permission(self, request, view, obj): + # Checks if the user has the require permissions + # To access the submission data in reports + user = request.user + if user.is_superuser: + return True + if user.is_anonymous: + user = get_anonymous_user() + permissions = list(obj.get_perms(user)) + required_permissions = [ + PERM_VIEW_SUBMISSIONS, + PERM_PARTIAL_SUBMISSIONS, + ] + return any( + perm in permissions for perm in required_permissions + ) From 57373d924eae7c2606020b4d4061dc06a96dc215 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 30 Apr 2019 16:05:08 -0400 Subject: [PATCH 019/499] added new unittest for implied restricted permissions --- kpi/tests/test_api_submissions.py | 258 ++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 kpi/tests/test_api_submissions.py diff --git a/kpi/tests/test_api_submissions.py b/kpi/tests/test_api_submissions.py new file mode 100644 index 0000000000..529b52b4bf --- /dev/null +++ b/kpi/tests/test_api_submissions.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +import json +import requests +import responses + +from django.conf import settings +from django.contrib.auth import get_user +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from kpi.models import Asset +from kpi.constants import INSTANCE_FORMAT_TYPE_JSON +from .kpi_test_case import KpiTestCase + + +class BaseTestCase(APITestCase): + fixtures = ["test_data"] + + """ + SubmissionViewset uses `BrowsableAPIRenderer` as the first renderer. + Force JSON to test the API by specifying `format`, `HTTP_ACCEPT` or + `content_type` + """ + + def setUp(self): + self.client.login(username="someuser", password="someuser") + user = User.objects.get(username="someuser") + self.anotheruser = User.objects.get(username="anotheruser") + asset_template = Asset.objects.get(id=1) + self.asset = Asset.objects.create(content=asset_template.content, + owner=user, + asset_type='survey') + + self.asset.deploy(backend='mock', active=True) + self.asset.save() + + v_uid = self.asset.latest_deployed_version.uid + self.submissions = [ + { + "__version__": v_uid, + "q1": "a1", + "q2": "a2", + "id": 1, + "_validation_status": { + "by_whom": "someuser", + "timestamp": 1547839938, + "uid": "validation_status_on_hold", + "color": "#0000ff", + "label": "On Hold" + } + }, + { + "__version__": v_uid, + "q1": "a3", + "q2": "a4", + "id": 2, + "_validation_status": { + "by_whom": "someuser", + "timestamp": 1547839938, + "uid": "validation_status_approved", + "color": "#0000ff", + "label": "On Hold" + } + } + ] + self.asset.deployment.mock_submissions(self.submissions) + self.submission_url = self.asset.deployment.submission_list_url + + def _other_user_login(self, shared_asset=False): + self.client.logout() + self.client.login(username="anotheruser", password="anotheruser") + if shared_asset: + self.asset.assign_perm(self.anotheruser, "view_submissions") + + +class SubmissionApiTests(BaseTestCase): + + def test_create_submission(self): + v_uid = self.asset.latest_deployed_version.uid + submission = { + "q1": "a5", + "q2": "a6", + } + # Owner + response = self.client.post(self.submission_url, data=submission) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + # Shared + self._other_user_login(True) + response = self.client.post(self.submission_url, data=submission) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # Anonymous + self.client.logout() + response = self.client.post(self.submission_url, data=submission) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_submissions_owner(self): + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.submissions) + + def test_list_submissions_not_shared_other(self): + self._other_user_login() + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_list_submissions_shared_other(self): + self._other_user_login(True) + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.submissions) + + def test_list_submissions_anonymous(self): + self.client.logout() + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_submission_owner(self): + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + + response = self.client.get(url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, submission) + + def test_retrieve_submission_not_shared_other(self): + self._other_user_login() + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + response = self.client.get(url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_retrieve_submission_shared_other(self): + self._other_user_login(True) + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + response = self.client.get(url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, submission) + + def test_delete_submission_owner(self): + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + + response = self.client.delete(url, + content_type="application/json", + HTTP_ACCEPT="application/json") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_delete_submission_anonymous(self): + self.client.logout() + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + + response = self.client.delete(url, + content_type="application/json", + HTTP_ACCEPT="application/json") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_submission_not_shared_other(self): + self._other_user_login() + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + + response = self.client.delete(url, + content_type="application/json", + HTTP_ACCEPT="application/json") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_submission_shared_other_no_write(self): + self._other_user_login(True) + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + response = self.client.delete(url, + content_type="application/json", + HTTP_ACCEPT="application/json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_submission_shared_other_write(self): + self._other_user_login(True) + self.asset.assign_perm(self.anotheruser, "change_submissions") + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + response = self.client.delete(url, + content_type="application/json", + HTTP_ACCEPT="application/json") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + +class SubmissionEditApiTests(BaseTestCase): + + def setUp(self): + super(SubmissionEditApiTests, self).setUp() + self.submission = self.submissions[0] + self.submission_url = reverse("submission-edit", kwargs={ + "parent_lookup_asset": self.asset.uid, + "pk": self.submission.get("id") + }) + + def test_trigger_signal(self): + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + expected_response = { + "url": "http://server.mock/enketo/{}".format(self.submission.get("id")) + } + self.assertEqual(response.data, expected_response) + + def test_get_edit_link_submission_anonymous(self): + self.client.logout() + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_get_edit_link_submission_shared_other(self): + self._other_user_login() + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class SubmissionValidationStatusApiTests(BaseTestCase): + + # @TODO Test PATCH + + def setUp(self): + super(SubmissionValidationStatusApiTests, self).setUp() + self.submission = self.submissions[0] + self.validation_status_url = self.asset.deployment.get_submission_validation_status_url( + self.submission.get("id")) + + def test_submission_validate_status_owner(self): + response = self.client.get(self.validation_status_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.submission.get("_validation_status")) + + def test_submission_validate_status_not_shared_other(self): + self._other_user_login() + response = self.client.get(self.validation_status_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_submission_validate_status_other(self): + self._other_user_login(True) + response = self.client.get(self.validation_status_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, self.submission.get("_validation_status")) + + def test_submission_validate_status_anonymous(self): + self.client.logout() + response = self.client.get(self.validation_status_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class SubmissionRestrictedApiTests(BaseTestCase): + pass From 4db84fb565a4d4332c479e8dbba79556a8924f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Tue, 30 Apr 2019 17:55:30 -0400 Subject: [PATCH 020/499] Added unitest: SubmissionView authentication with restricted permissions --- kpi/deployment_backends/mock_backend.py | 8 +++++++ kpi/tests/test_api_submissions.py | 32 ++++++++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py index 8eedaca005..e0e3d3aa6f 100644 --- a/kpi/deployment_backends/mock_backend.py +++ b/kpi/deployment_backends/mock_backend.py @@ -199,6 +199,14 @@ def get_submissions(self, requesting_user_id, if 'limit' in params: submissions = submissions[:params['limit']] + if submitted_by: + if format_type == INSTANCE_FORMAT_TYPE_XML: + # TODO handle `submitted_by` too. + pass + else: + submissions = [submission for submission in submissions + if submission.get('submitted_by') in submitted_by] + return submissions def get_validation_status(self, submission_pk, params, user): diff --git a/kpi/tests/test_api_submissions.py b/kpi/tests/test_api_submissions.py index 529b52b4bf..3447750a76 100644 --- a/kpi/tests/test_api_submissions.py +++ b/kpi/tests/test_api_submissions.py @@ -13,7 +13,8 @@ from rest_framework.test import APITestCase from kpi.models import Asset -from kpi.constants import INSTANCE_FORMAT_TYPE_JSON +from kpi.constants import INSTANCE_FORMAT_TYPE_JSON, PERM_VIEW_SUBMISSIONS,\ + PERM_RESTRICTED_SUBMISSIONS from .kpi_test_case import KpiTestCase @@ -28,11 +29,11 @@ class BaseTestCase(APITestCase): def setUp(self): self.client.login(username="someuser", password="someuser") - user = User.objects.get(username="someuser") + self.someuser = User.objects.get(username="someuser") self.anotheruser = User.objects.get(username="anotheruser") asset_template = Asset.objects.get(id=1) self.asset = Asset.objects.create(content=asset_template.content, - owner=user, + owner=self.someuser, asset_type='survey') self.asset.deploy(backend='mock', active=True) @@ -51,7 +52,8 @@ def setUp(self): "uid": "validation_status_on_hold", "color": "#0000ff", "label": "On Hold" - } + }, + "submitted_by": "" }, { "__version__": v_uid, @@ -64,7 +66,8 @@ def setUp(self): "uid": "validation_status_approved", "color": "#0000ff", "label": "On Hold" - } + }, + "submitted_by": "someuser" } ] self.asset.deployment.mock_submissions(self.submissions) @@ -74,7 +77,7 @@ def _other_user_login(self, shared_asset=False): self.client.logout() self.client.login(username="anotheruser", password="anotheruser") if shared_asset: - self.asset.assign_perm(self.anotheruser, "view_submissions") + self.asset.assign_perm(self.anotheruser, PERM_VIEW_SUBMISSIONS) class SubmissionApiTests(BaseTestCase): @@ -115,6 +118,23 @@ def test_list_submissions_shared_other(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, self.submissions) + def test_list_submissions_restricted_permissions(self): + self._other_user_login() + restricted_perms = { + PERM_VIEW_SUBMISSIONS: [self.someuser.username] + } + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + self.asset.assign_perm(self.anotheruser, PERM_RESTRICTED_SUBMISSIONS, + restricted_perms=restricted_perms) + response = self.client.get(self.submission_url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(self.asset.deployment.submission_count == 2) + # User `anotheruser` should only see submissions where `submitted_by` + # is filled up and equals to `someuser` + self.assertTrue(len(response.data) == 1) + def test_list_submissions_anonymous(self): self.client.logout() response = self.client.get(self.submission_url, {"format": "json"}) From a8ec0be3ef9ccc746b6a12aa0747bfc447a4215f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 1 May 2019 11:23:19 -0400 Subject: [PATCH 021/499] Force data to be XML when calling XML format --- dependencies/pip/dev_requirements.txt | 108 ------------------------- dependencies/pip/external_services.txt | 74 ----------------- dependencies/pip/requirements.txt | 83 ------------------- 3 files changed, 265 deletions(-) diff --git a/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index 180b65d5c0..5f24001ead 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -24,7 +24,6 @@ celery[redis]==4.3.0 # via -r dependencies/pip/requirements.in certifi==2019.9.11 # via requests cffi==1.13.2 # via bcrypt, cryptography, pynacl chardet==3.0.4 # via requests -<<<<<<< HEAD cryptography==2.8 # via paramiko, pyopenssl cssselect==1.1.0 # via pyquery decorator==4.4.1 # via ipython, traitlets @@ -68,56 +67,6 @@ geojson-rewind==0.2.0 # via -r dependencies/pip/requirements.in, formpack idna==2.8 # via requests importlib-metadata==0.23 # via jsonschema, kombu, path.py invoke==1.3.0 # via fabric -======= -configparser==3.7.1 # via importlib-metadata -contextlib2==0.5.5 # via importlib-metadata -cookies==2.2.1 # via responses -cryptography==2.2.2 # via fabric, paramiko, pyopenssl -cssselect==1.0.3 # via pyquery -cyordereddict==1.0.0 -decorator==4.3.2 # via ipython, traitlets -defusedxml==0.5.0 # via djangorestframework-xml -dj-database-url==0.4.1 -dj-static==0.0.6 -django-braces==1.13.0 -django-celery-beat==1.1.1 -django-constance[database]==2.2.0 -django-debug-toolbar==1.4 -django-extensions==1.6.7 -django-haystack==2.6.0 -django-jsonbfield==0.1.0 -django-loginas==0.2.3 -django-markitup==3.0.0 -django-mptt==0.8.7 -django-oauth-toolkit==0.10.0 -django-picklefield==1.0.0 # via django-constance -django-private-storage==2.1.2 -django-redis-sessions==0.6.1 -django-registration-redux==1.3 -django-reversion==2.0.8 -django-ses==0.8.9 -django-storages==1.6.5 -django-taggit==0.22.0 -django-toolbelt==0.0.1 -django-webpack-loader==0.3.0 -django==1.8.19 -djangorestframework-xml==1.4.0 -djangorestframework==3.6.4 -docutils==0.14 # via botocore, statistics -drf-extensions==0.3.1 -enum34==1.1.6 # via cryptography, traitlets -fabric==2.4.0 -formencode==1.3.1 # via pyxform -funcsigs==1.0.2 # via begins, mock -functools32==3.2.3.post2 # via jsonschema -future==0.17.1 # via backports.os, django-ses -futures==3.2.0 # via s3transfer -gunicorn==19.4.5 -idna==2.8 # via cryptography, requests -importlib-metadata==0.8 # via path.py -invoke==1.2.0 # via fabric -ipaddress==1.0.17 # via cryptography ->>>>>>> Use Redis as session storage ipython-genutils==0.2.0 # via traitlets ipython==7.9.0 # via -r dependencies/pip/dev_requirements.in jedi==0.15.1 # via ipython @@ -126,7 +75,6 @@ jsonfield==2.0.2 # via -r dependencies/pip/requirements.in jsonschema==3.1.1 # via formpack kombu==4.6.6 # via -r dependencies/pip/requirements.in, celery linecache2==1.0.0 # via traceback2 -<<<<<<< HEAD lxml==4.4.1 # via -r dependencies/pip/requirements.in, formpack, pyquery markdown==3.1.1 # via -r dependencies/pip/requirements.in, django-markdownx mock==3.0.5 # via -r dependencies/pip/dev_requirements.in @@ -138,25 +86,12 @@ paramiko==2.6.0 # via fabric parso==0.5.1 # via jedi path.py==12.0.2 # via formpack pexpect==4.7.0 # via ipython -======= -lxml==4.3.0 -markdown==3.0.1 -mock==2.0.0 -ndg-httpsclient==0.4.2 -oauthlib==1.0.3 -paramiko==2.4.2 # via fabric -path.py==11.5.0 -pathlib2==2.3.3 # via importlib-metadata, ipython, pickleshare -pbr==4.0.2 # via mock -pexpect==4.6.0 # via ipython ->>>>>>> Use Redis as session storage pickleshare==0.7.5 # via ipython pillow==6.2.1 # via django-markdownx pluggy==0.13.0 # via pytest prompt-toolkit==2.0.10 # via ipython psycopg2==2.8.4 # via -r dependencies/pip/requirements.in ptyprocess==0.6.0 # via pexpect -<<<<<<< HEAD py==1.8.0 # via pytest pyasn1==0.4.7 # via -r dependencies/pip/requirements.in, ndg-httpsclient pycparser==2.19 # via cffi @@ -184,40 +119,10 @@ sqlparse==0.3.0 # via -r dependencies/pip/requirements.in, django, dja static3==0.7.0 # via -r dependencies/pip/requirements.in, dj-static statistics==1.0.3.5 # via formpack tabulate==0.8.5 # via -r dependencies/pip/requirements.in -======= -py==1.4.31 # via pytest -pyasn1==0.1.9 -pycparser==2.14 # via cffi -pygments==2.1.3 -pymongo==3.7.2 -pynacl==1.3.0 # via paramiko -pyopenssl==18.0.0 -pyquery==1.4.0 -pytest-django==3.1.2 -pytest-env==0.6.2 -pytest==3.0.3 # via pytest-django, pytest-env -python-dateutil==2.7.5 -python-digest==1.7 -pytz==2018.9 -pyxform==0.12.0 -redis==3.1.0 # via django-redis-sessions -requests==2.21.0 -responses==0.9.0 -s3transfer==0.1.13 # via boto3 -scandir==1.9.0 # via pathlib2 -shortuuid==0.4.3 -simplegeneric==0.8.1 # via ipython -six==1.12.0 -sqlparse==0.1.19 -static3==0.7.0 -statistics==1.0.3.5 -tabulate==0.8.2 ->>>>>>> Use Redis as session storage traceback2==1.4.0 # via unittest2 traitlets==4.3.3 # via ipython unicodecsv==0.14.1 # via -r dependencies/pip/requirements.in, pyxform unittest2==1.1.0 # via pyxform -<<<<<<< HEAD urllib3==1.25.7 # via botocore, requests uwsgi==2.0.18 # via -r dependencies/pip/requirements.in vine==1.3.0 # via amqp, celery @@ -231,16 +136,3 @@ zipp==0.6.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip # setuptools -======= -urllib3==1.24.1 # via botocore, requests -uwsgi==2.0.17 -vine==1.2.0 # via amqp -wcwidth==0.1.7 # via prompt-toolkit -werkzeug==0.14.1 -whitenoise==3.3.1 -whoosh==2.7.4 -xlrd==1.1.0 -xlsxwriter==1.1.2 -xlwt==1.3.0 -zipp==0.3.3 # via importlib-metadata ->>>>>>> Use Redis as session storage diff --git a/dependencies/pip/external_services.txt b/dependencies/pip/external_services.txt index f16207ea19..cc467066b1 100644 --- a/dependencies/pip/external_services.txt +++ b/dependencies/pip/external_services.txt @@ -20,7 +20,6 @@ celery[redis]==4.3.0 # via -r dependencies/pip/requirements.in certifi==2019.9.11 # via requests cffi==1.13.2 # via cryptography chardet==3.0.4 # via requests -<<<<<<< HEAD cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery defusedxml==0.6.0 # via djangorestframework-xml @@ -56,44 +55,6 @@ djangorestframework-xml==1.4.0 # via -r dependencies/pip/requirements.in djangorestframework==3.10.3 # via -r dependencies/pip/requirements.in, drf-extensions docutils==0.15.2 # via botocore, statistics drf-extensions==0.5.0 # via -r dependencies/pip/requirements.in -======= -configparser==3.7.1 # via importlib-metadata -contextlib2==0.5.5 # via importlib-metadata, raven -cookies==2.2.1 # via responses -cryptography==2.2.2 # via pyopenssl -cssselect==1.0.3 # via pyquery -cyordereddict==1.0.0 -defusedxml==0.5.0 # via djangorestframework-xml -dj-database-url==0.4.1 -dj-static==0.0.6 -django-braces==1.13.0 -django-celery-beat==1.1.1 -django-constance[database]==2.2.0 -django-debug-toolbar==1.4 -django-extensions==1.6.7 -django-haystack==2.6.0 -django-jsonbfield==0.1.0 -django-loginas==0.2.3 -django-markitup==3.0.0 -django-mptt==0.8.7 -django-oauth-toolkit==0.10.0 -django-picklefield==1.0.0 # via django-constance -django-private-storage==2.1.2 -django-redis-sessions==0.6.1 -django-registration-redux==1.3 -django-reversion==2.0.8 -django-ses==0.8.9 -django-storages==1.6.5 -django-taggit==0.22.0 -django-toolbelt==0.0.1 -django-webpack-loader==0.3.0 -django==1.8.19 -djangorestframework-xml==1.4.0 -djangorestframework==3.6.4 -docutils==0.14 # via botocore, statistics -drf-extensions==0.3.1 -enum34==1.1.6 # via cryptography ->>>>>>> Use Redis as session storage formencode==1.3.1 # via pyxform future==0.18.2 # via -r dependencies/pip/requirements.in geojson-rewind==0.2.0 # via -r dependencies/pip/requirements.in, formpack @@ -104,7 +65,6 @@ jsonfield==2.0.2 # via -r dependencies/pip/requirements.in jsonschema==3.1.1 # via formpack kombu==4.6.6 # via -r dependencies/pip/requirements.in, celery linecache2==1.0.0 # via traceback2 -<<<<<<< HEAD lxml==4.4.1 # via -r dependencies/pip/requirements.in, formpack, pyquery markdown==3.1.1 # via -r dependencies/pip/requirements.in, django-markdownx more-itertools==7.2.0 # via zipp @@ -135,40 +95,6 @@ sqlparse==0.3.0 # via -r dependencies/pip/requirements.in, django, dja static3==0.7.0 # via -r dependencies/pip/requirements.in, dj-static statistics==1.0.3.5 # via formpack tabulate==0.8.5 # via -r dependencies/pip/requirements.in -======= -lxml==4.3.0 -markdown==3.0.1 -mock==2.0.0 -ndg-httpsclient==0.4.2 -newrelic==2.84.0.64 -oauthlib==1.0.3 -path.py==11.5.0 -pathlib2==2.3.3 # via importlib-metadata -pbr==4.0.2 # via mock -psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt -pyasn1==0.1.9 -pycparser==2.14 # via cffi -pygments==2.1.3 -pymongo==3.7.2 -pyopenssl==18.0.0 -pyquery==1.4.0 -python-dateutil==2.7.5 -python-digest==1.7 -pytz==2018.9 -pyxform==0.12.0 -raven==5.32.0 -redis==3.1.0 # via django-redis-sessions -requests==2.21.0 -responses==0.9.0 -s3transfer==0.1.13 # via boto3 -scandir==1.9.0 # via pathlib2 -shortuuid==0.4.3 -six==1.12.0 -sqlparse==0.1.19 -static3==0.7.0 -statistics==1.0.3.5 -tabulate==0.8.2 ->>>>>>> Use Redis as session storage traceback2==1.4.0 # via unittest2 transifex-client==0.12.5 # via -r dependencies/pip/external_services.in unicodecsv==0.14.1 # via -r dependencies/pip/requirements.in, pyxform diff --git a/dependencies/pip/requirements.txt b/dependencies/pip/requirements.txt index 10badf44ce..97d7c0aafe 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -21,7 +21,6 @@ celery[redis]==4.3.0 # via -r dependencies/pip/requirements.in certifi==2019.9.11 # via requests cffi==1.13.2 # via cryptography chardet==3.0.4 # via requests -<<<<<<< HEAD cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery defusedxml==0.6.0 # via djangorestframework-xml @@ -57,44 +56,6 @@ djangorestframework-xml==1.4.0 # via -r dependencies/pip/requirements.in djangorestframework==3.10.3 # via -r dependencies/pip/requirements.in, drf-extensions docutils==0.15.2 # via botocore, statistics drf-extensions==0.5.0 # via -r dependencies/pip/requirements.in -======= -configparser==3.7.1 # via importlib-metadata -contextlib2==0.5.5 # via importlib-metadata -cookies==2.2.1 # via responses -cryptography==2.2.2 # via pyopenssl -cssselect==1.0.3 # via pyquery -cyordereddict==1.0.0 -defusedxml==0.5.0 # via djangorestframework-xml -dj-database-url==0.4.1 -dj-static==0.0.6 -django-braces==1.13.0 -django-celery-beat==1.1.1 -django-constance[database]==2.2.0 -django-debug-toolbar==1.4 -django-extensions==1.6.7 -django-haystack==2.6.0 -django-jsonbfield==0.1.0 -django-loginas==0.2.3 -django-markitup==3.0.0 -django-mptt==0.8.7 -django-oauth-toolkit==0.10.0 -django-picklefield==1.0.0 # via django-constance -django-private-storage==2.1.2 -django-redis-sessions==0.6.1 -django-registration-redux==1.3 -django-reversion==2.0.8 -django-ses==0.8.9 -django-storages==1.6.5 -django-taggit==0.22.0 -django-toolbelt==0.0.1 -django-webpack-loader==0.3.0 -django==1.8.19 -djangorestframework-xml==1.4.0 -djangorestframework==3.6.4 -docutils==0.14 # via botocore, statistics -drf-extensions==0.3.1 -enum34==1.1.6 # via cryptography ->>>>>>> Use Redis as session storage formencode==1.3.1 # via pyxform future==0.18.2 # via -r dependencies/pip/requirements.in geojson-rewind==0.2.0 # via -r dependencies/pip/requirements.in, formpack @@ -105,7 +66,6 @@ jsonfield==2.0.2 # via -r dependencies/pip/requirements.in jsonschema==3.1.1 # via formpack kombu==4.6.6 # via -r dependencies/pip/requirements.in, celery linecache2==1.0.0 # via traceback2 -<<<<<<< HEAD lxml==4.4.1 # via -r dependencies/pip/requirements.in, formpack, pyquery markdown==3.1.1 # via -r dependencies/pip/requirements.in, django-markdownx more-itertools==7.2.0 # via zipp @@ -135,42 +95,9 @@ sqlparse==0.3.0 # via -r dependencies/pip/requirements.in, django, dja static3==0.7.0 # via -r dependencies/pip/requirements.in, dj-static statistics==1.0.3.5 # via formpack tabulate==0.8.5 # via -r dependencies/pip/requirements.in -======= -lxml==4.3.0 -markdown==3.0.1 -mock==2.0.0 -ndg-httpsclient==0.4.2 -oauthlib==1.0.3 -path.py==11.5.0 -pathlib2==2.3.3 # via importlib-metadata -pbr==4.0.2 # via mock -psycopg2==2.7.7 # via django-jsonbfield, django-toolbelt -pyasn1==0.1.9 -pycparser==2.14 # via cffi -pygments==2.1.3 -pymongo==3.7.2 -pyopenssl==18.0.0 -pyquery==1.4.0 -python-dateutil==2.7.5 -python-digest==1.7 -pytz==2018.9 -pyxform==0.12.0 -redis==3.1.0 # via django-redis-sessions -requests==2.21.0 -responses==0.9.0 -s3transfer==0.1.13 # via boto3 -scandir==1.9.0 # via pathlib2 -shortuuid==0.4.3 -six==1.12.0 -sqlparse==0.1.19 -static3==0.7.0 -statistics==1.0.3.5 -tabulate==0.8.2 ->>>>>>> Use Redis as session storage traceback2==1.4.0 # via unittest2 unicodecsv==0.14.1 # via -r dependencies/pip/requirements.in, pyxform unittest2==1.1.0 # via pyxform -<<<<<<< HEAD urllib3==1.25.7 # via botocore, requests uwsgi==2.0.18 # via -r dependencies/pip/requirements.in vine==1.3.0 # via amqp, celery @@ -178,16 +105,6 @@ xlrd==1.2.0 # via -r dependencies/pip/requirements.in, pyxform xlsxwriter==1.2.5 # via -r dependencies/pip/requirements.in, formpack xlwt==1.3.0 # via -r dependencies/pip/requirements.in zipp==0.6.0 # via importlib-metadata -======= -urllib3==1.24.1 # via botocore, requests -uwsgi==2.0.17 -whitenoise==3.3.1 -whoosh==2.7.4 -xlrd==1.1.0 -xlsxwriter==1.1.2 -xlwt==1.3.0 -zipp==0.3.3 # via importlib-metadata ->>>>>>> Install lower version of celery and include previous files with celery imports # The following packages are considered to be unsafe in a requirements file: # pip From 8c649ce66cc6f2df19b099b274f2d754062f8e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 1 May 2019 14:38:28 -0400 Subject: [PATCH 022/499] Fixed bug when user with restricted permissions could access all data in XML --- kpi/tests/test_api_submissions.py | 22 +++++++++++++++++++++- kpi/views.py | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/kpi/tests/test_api_submissions.py b/kpi/tests/test_api_submissions.py index 3447750a76..145b92ccf1 100644 --- a/kpi/tests/test_api_submissions.py +++ b/kpi/tests/test_api_submissions.py @@ -118,7 +118,7 @@ def test_list_submissions_shared_other(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, self.submissions) - def test_list_submissions_restricted_permissions(self): + def test_list_submissions_with_restricted_permissions(self): self._other_user_login() restricted_perms = { PERM_VIEW_SUBMISSIONS: [self.someuser.username] @@ -163,6 +163,26 @@ def test_retrieve_submission_shared_other(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, submission) + def test_retrieve_submission_with_restricted_permissions(self): + self._other_user_login() + restricted_perms = { + PERM_VIEW_SUBMISSIONS: [self.someuser.username] + } + self.asset.assign_perm(self.anotheruser, PERM_RESTRICTED_SUBMISSIONS, + restricted_perms=restricted_perms) + + # Try first submission submitted by unknown + submission = self.submissions[0] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + response = self.client.get(url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Try second submission submitted by someuser + submission = self.submissions[1] + url = self.asset.deployment.get_submission_detail_url(submission.get("id")) + response = self.client.get(url, {"format": "json"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_delete_submission_owner(self): submission = self.submissions[0] url = self.asset.deployment.get_submission_detail_url(submission.get("id")) diff --git a/kpi/views.py b/kpi/views.py index 54bf9fff22..c4d68ac577 100644 --- a/kpi/views.py +++ b/kpi/views.py @@ -1003,6 +1003,8 @@ def retrieve(self, request, pk, *args, **kwargs): # remove `format` from filters, it's redundant. filters.pop('format', None) submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 return Response(submission) @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) From 3240f65dfbd467174fd1ca5fc25f55e5969ae146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 1 May 2019 16:35:03 -0400 Subject: [PATCH 023/499] Reorganized imports in url.py for future split --- kpi/urls.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/kpi/urls.py b/kpi/urls.py index 5f5274402f..42202d89b4 100644 --- a/kpi/urls.py +++ b/kpi/urls.py @@ -5,6 +5,12 @@ from rest_framework_extensions.routers import ExtendedDefaultRouter import private_storage.urls +from hub.models import ConfigurationFile +from hub.views import switch_builder +from kobo.apps.hook.views import HookViewSet, HookLogViewSet +from kobo.apps.reports.views import ReportsViewSet +from kobo.apps.superuser_stats.views import user_report, retrieve_user_report +from kpi.forms import RegistrationForm from kpi.views import ( AssetViewSet, AssetVersionViewSet, @@ -27,14 +33,9 @@ EnvironmentView, ) -from kpi.views import home, one_time_login, browser_tests -from kobo.apps.reports.views import ReportsViewSet -from kobo.apps.superuser_stats.views import user_report, retrieve_user_report from kpi.views import authorized_application_authenticate_user -from kpi.forms import RegistrationForm -from hub.views import switch_builder -from hub.models import ConfigurationFile -from kobo.apps.hook.views import HookViewSet, HookLogViewSet +from kpi.views import home, one_time_login, browser_tests + # TODO: Give other apps their own `urls.py` files instead of importing their # views directly! See @@ -67,7 +68,7 @@ HookViewSet, base_name='hook', parents_query_lookups=['asset'], - ) + ) hook_routes.register(r'logs', HookLogViewSet, From a878028532bd4658d5cca2db30f2db2084da204c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 1 May 2019 16:58:54 -0400 Subject: [PATCH 024/499] WIP - splitted views.py in several files --- kpi/views/__init__.py | 1624 ++++++++++++++++++++ kpi/{views.py => views/asset.py} | 298 ++-- kpi/views/asset_file.py | 1638 +++++++++++++++++++++ kpi/views/asset_snapshot.py | 1638 +++++++++++++++++++++ kpi/views/asset_version.py | 1638 +++++++++++++++++++++ kpi/views/authorized_application_user.py | 1638 +++++++++++++++++++++ kpi/views/collection.py | 1638 +++++++++++++++++++++ kpi/views/current_user.py | 1636 ++++++++++++++++++++ kpi/views/environment.py | 1638 +++++++++++++++++++++ kpi/views/export_task.py | 1638 +++++++++++++++++++++ kpi/views/hook_signal.py | 1638 +++++++++++++++++++++ kpi/views/import_task.py | 1638 +++++++++++++++++++++ kpi/views/no_update_model.py | 1635 ++++++++++++++++++++ kpi/views/object_permission.py | 1638 +++++++++++++++++++++ kpi/views/one_time_authentication_key.py | 1638 +++++++++++++++++++++ kpi/views/sitewide_message.py | 1638 +++++++++++++++++++++ kpi/views/submission.py | 1638 +++++++++++++++++++++ kpi/views/tag.py | 1638 +++++++++++++++++++++ kpi/views/token.py | 1618 ++++++++++++++++++++ kpi/views/user.py | 1638 +++++++++++++++++++++ kpi/views/user_collection_subscription.py | 1638 +++++++++++++++++++++ 21 files changed, 32843 insertions(+), 176 deletions(-) rename kpi/{views.py => views/asset.py} (91%) create mode 100644 kpi/views/asset_file.py create mode 100644 kpi/views/asset_snapshot.py create mode 100644 kpi/views/asset_version.py create mode 100644 kpi/views/authorized_application_user.py create mode 100644 kpi/views/collection.py create mode 100644 kpi/views/export_task.py create mode 100644 kpi/views/hook_signal.py create mode 100644 kpi/views/import_task.py create mode 100644 kpi/views/object_permission.py create mode 100644 kpi/views/one_time_authentication_key.py create mode 100644 kpi/views/sitewide_message.py create mode 100644 kpi/views/submission.py create mode 100644 kpi/views/tag.py create mode 100644 kpi/views/user.py create mode 100644 kpi/views/user_collection_subscription.py diff --git a/kpi/views/__init__.py b/kpi/views/__init__.py index ee9f6dcad8..d149231057 100644 --- a/kpi/views/__init__.py +++ b/kpi/views/__init__.py @@ -1,9 +1,18 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +import base64 +import copy +>>>>>>> WIP - splitted views.py in several files import datetime import json from hashlib import md5 from itertools import chain +<<<<<<< HEAD from django.contrib.auth.decorators import login_required from django.template.response import TemplateResponse from django.conf import settings @@ -12,10 +21,25 @@ from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import resolve_url +======= +import constance + +from django.conf import settings +from django.contrib.auth import login, logout +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.db import transaction +from django.db.models import Q +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +>>>>>>> WIP - splitted views.py in several files from django.utils.http import is_safe_url from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST +<<<<<<< HEAD from rest_framework import exceptions from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, authentication_classes @@ -25,6 +49,111 @@ from kpi.models.authorized_application import ApplicationTokenAuthentication from kpi.serializers import AuthorizedApplicationUserSerializer from ona.authentication import JWTAuthentication +======= +from private_storage.views import PrivateStorageDetailView +from rest_framework import exceptions, mixins, renderers, status, viewsets +from rest_framework.authtoken.models import Token +from rest_framework.decorators import ( + api_view, + authentication_classes, + detail_route, + list_route +) +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin +from taggit.models import Tag + +from hub.models import SitewideMessage +from kobo.apps.hook.utils import HookUtils +from kobo.static_lists import COUNTRIES, LANGUAGES, SECTORS +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging +from .constants import ( + ASSET_TYPES, + ASSET_TYPE_ARG_NAME, + ASSET_TYPE_SURVEY, + ASSET_TYPE_TEMPLATE, + CLONE_ARG_NAME, + CLONE_COMPATIBLE_TYPES, + CLONE_FROM_VERSION_ID_ARG_NAME, + COLLECTION_CLONE_FIELDS, +) +from .deployment_backends.backends import DEPLOYMENT_BACKENDS +from .filters import ( + AssetOwnerFilterBackend, + KpiAssignedObjectPermissionsFilter, + KpiObjectPermissionsFilter, + RelatedAssetPermissionsFilter, + SearchFilter +) +from .highlighters import highlight_xform +from .model_utils import disable_auto_field_update, remove_string_prefix +from .models import ( + Asset, + AssetFile, + AssetSnapshot, + AssetVersion, + AuthorizedApplication, + Collection, + ExportTask, + ImportTask, + ObjectPermission, + OneTimeAuthenticationKey, + UserCollectionSubscription +) +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer, +) +from .serializers import ( + AssetFileSerializer, + AssetListSerializer, + AssetSerializer, + AssetSnapshotSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AuthorizedApplicationUserSerializer, + CollectionListSerializer, + CollectionSerializer, + CreateUserSerializer, + CurrentUserSerializer, + DeploymentSerializer, + ExportTaskSerializer, + ImportTaskListSerializer, + ImportTaskSerializer, + ObjectPermissionSerializer, + OneTimeAuthenticationKeySerializer, + SitewideMessageSerializer, + TagListSerializer, + TagSerializer, + UserCollectionSubscriptionSerializer, + UserSerializer +) +from .tasks import import_in_background, export_in_background +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable + +from ona.authentication import ( + JWTAuthentication, encode_payload, decode_payload +) +from .model_utils import grant_default_model_level_perms +>>>>>>> WIP - splitted views.py in several files def home(request): @@ -41,6 +170,7 @@ def browser_tests(request): return TemplateResponse(request, "browser_tests.html") +<<<<<<< HEAD @api_view(['POST']) @authentication_classes([ApplicationTokenAuthentication]) def authorized_application_authenticate_user(request): @@ -48,6 +178,270 @@ def authorized_application_authenticate_user(request): Returns a user-level API token when given a valid username and password. The request header must include an authorized application key """ +======= +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous: + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + @detail_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer]) + def grant_default_model_level_perms(self, request, *args, **kwargs): + user = self.get_object() + grant_default_model_level_perms(user) + + return Response( + data={ + "detail": ("Successfully granted default model level " + "perms to user %s." % user.username) + }, + status=status.HTTP_201_CREATED + ) + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' +>>>>>>> WIP - splitted views.py in several files if type(request.auth) is not AuthorizedApplication: # Only specially-authorized applications are allowed to authenticate # users this way @@ -80,6 +474,7 @@ def authorized_application_authenticate_user(request): return Response(response_data) +<<<<<<< HEAD @require_POST @csrf_exempt def one_time_login(request): @@ -88,6 +483,31 @@ def one_time_login(request): object, log in the User specified in that object and redirect to the location specified in the 'next' parameter """ +======= +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' +>>>>>>> WIP - splitted views.py in several files try: key = request.POST['key'] except KeyError: @@ -118,6 +538,1210 @@ def one_time_login(request): return HttpResponseRedirect(next_) +<<<<<<< HEAD # TODO Verify if it's still used def _wrap_html_pre(content): return "
%s
" % content +======= +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous: + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous: + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous: + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous: + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous: + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + + instance_id = request.data.get("instance_id") + if instance_id is None: + raise exceptions.ValidationError( + {'instance_id': _('This field is required.')}) + + instance = None + try: + instance = asset.deployment.get_submission(instance_id) + except ValueError: + raise Http404 + + # Check if instance really belongs to Asset. + if not (instance and + instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + raise Http404 + + if HookUtils.call_services(asset, instance_id): + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + else: + # call_services() refused to launch any task because this + # instance already has a `HookLog` + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for editing._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get('format', request.GET.get('format', 'json')) + deployment = self._get_deployment() + filters = request.GET.dict() + # remove `format` from filters, it's redundant. + filters.pop('format', None) + # Do not allow requests to retrieve more than `SUBMISSION_LIST_LIMIT` + # submissions at one time + limit = filters.get('limit', settings.SUBMISSION_LIST_LIMIT) + try: + limit = int(limit) + except ValueError: + raise exceptions.ValidationError( + {'limit': _('A valid integer is required')} + ) + filters['limit'] = min(limit, settings.SUBMISSION_LIST_LIMIT) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get('format', request.GET.get('format', 'json')) + deployment = self._get_deployment() + filters = request.GET.dict() + # remove `format` from filters, it's redundant. + filters.pop('format', None) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validation_status(pk, request.data, request.user) + else: + json_response = deployment.get_validation_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validation_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backend` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + if asset.has_perm(request.user, "supervisor_view_submissions"): + pass + + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous: + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous:
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous: + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous: + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + """ + GET-only view for certain server-provided configuration data + """ + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + """ + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE`, along with the static lists of sectors, countries, + all known languages, and languages for which the interface has + translations. + """ + data = { + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + } + data['available_sectors'] = SECTORS + data['available_countries'] = COUNTRIES + data['all_languages'] = LANGUAGES + data['interface_languages'] = settings.LANGUAGES + return Response(data) +>>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views.py b/kpi/views/asset.py similarity index 91% rename from kpi/views.py rename to kpi/views/asset.py index c4d68ac577..1b9a7c435b 100644 --- a/kpi/views.py +++ b/kpi/views/asset.py @@ -1,86 +1,73 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -import base64 +from distutils.util import strtobool +from itertools import chain import copy -import datetime -import json from hashlib import md5 -from itertools import chain - -import constance +import json +import base64 +import datetime -from django.conf import settings -from django.contrib.auth import login, logout -from django.contrib.auth.decorators import login_required +from django.contrib.auth import login from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required from django.db import transaction -from django.db.models import Q +from django.db.models import Q, Count from django.forms import model_to_dict from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url from django.shortcuts import get_object_or_404, resolve_url from django.template.response import TemplateResponse -from django.utils.http import is_safe_url -from django.utils.translation import ugettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt +from django.conf import settings from django.views.decorators.http import require_POST -from private_storage.views import PrivateStorageDetailView -from rest_framework import exceptions, mixins, renderers, status, viewsets -from rest_framework.authtoken.models import Token -from rest_framework.decorators import ( - api_view, - authentication_classes, - detail_route, - list_route +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, ) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes from rest_framework.parsers import MultiPartParser from rest_framework.response import Response from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token from rest_framework.views import APIView from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView -from hub.models import SitewideMessage -from kobo.apps.hook.utils import HookUtils -from kobo.static_lists import COUNTRIES, LANGUAGES, SECTORS -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging -from .constants import ( - ASSET_TYPES, - ASSET_TYPE_ARG_NAME, - ASSET_TYPE_SURVEY, - ASSET_TYPE_TEMPLATE, - CLONE_ARG_NAME, - CLONE_COMPATIBLE_TYPES, - CLONE_FROM_VERSION_ID_ARG_NAME, - COLLECTION_CLONE_FIELDS, -) -from .deployment_backends.backends import DEPLOYMENT_BACKENDS -from .filters import ( - AssetOwnerFilterBackend, - KpiAssignedObjectPermissionsFilter, - KpiObjectPermissionsFilter, - RelatedAssetPermissionsFilter, - SearchFilter -) +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter from .highlighters import highlight_xform -from .model_utils import disable_auto_field_update, remove_string_prefix +from hub.models import SitewideMessage from .models import ( + Collection, Asset, - AssetFile, - AssetSnapshot, AssetVersion, - AuthorizedApplication, - Collection, - ExportTask, + AssetSnapshot, + AssetFile, ImportTask, + ExportTask, ObjectPermission, + AuthorizedApplication, OneTimeAuthenticationKey, - UserCollectionSubscription -) + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user from .models.authorized_application import ApplicationTokenAuthentication from .models.import_export_task import _resolve_url_to_asset_or_collection -from .models.object_permission import get_anonymous_user, get_objects_for_user +from .model_utils import disable_auto_field_update, remove_string_prefix from .permissions import ( IsOwnerOrReadOnly, PostMappedToChangePermission, @@ -93,49 +80,43 @@ XFormRenderer, XMLRenderer, SubmissionXMLRenderer, - XlsRenderer, -) + XlsRenderer,) from .serializers import ( - AssetFileSerializer, - AssetListSerializer, - AssetSerializer, - AssetSnapshotSerializer, + AssetSerializer, AssetListSerializer, AssetVersionListSerializer, AssetVersionSerializer, - AuthorizedApplicationUserSerializer, - CollectionListSerializer, - CollectionSerializer, - CreateUserSerializer, - CurrentUserSerializer, - DeploymentSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, ExportTaskSerializer, - ImportTaskListSerializer, - ImportTaskSerializer, ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, OneTimeAuthenticationKeySerializer, - SitewideMessageSerializer, - TagListSerializer, - TagSerializer, - UserCollectionSubscriptionSerializer, - UserSerializer -) -from .tasks import import_in_background, export_in_background + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url from .utils.kobo_to_xlsform import to_xlsform_structure from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS -from ona.authentication import ( - JWTAuthentication, encode_payload, decode_payload -) -from .model_utils import grant_default_model_level_perms +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging +@login_required def home(request): - cookie_jwt = request.COOKIES.get(settings.KPI_COOKIE_NAME) - if request.user.is_anonymous and cookie_jwt: - auth_class = JWTAuthentication() - user, token = auth_class.authenticate(request) - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) return TemplateResponse(request, "index.html") @@ -313,7 +294,7 @@ def get_queryset(self, *args, **kwargs): # Check if the user is anonymous. The # django.contrib.auth.models.AnonymousUser object doesn't work for # queries. - if user.is_anonymous: + if user.is_anonymous(): user = get_anonymous_user() def _get_tags_on_items(content_type_name, avail_items): @@ -371,19 +352,6 @@ class CurrentUserViewSet(viewsets.ModelViewSet): def get_object(self): return self.request.user - @detail_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer]) - def grant_default_model_level_perms(self, request, *args, **kwargs): - user = self.get_object() - grant_default_model_level_perms(user) - - return Response( - data={ - "detail": ("Successfully granted default model level " - "perms to user %s." % user.username) - }, - status=status.HTTP_201_CREATED - ) - class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): @@ -444,6 +412,7 @@ class OneTimeAuthenticationKeyViewSet( authentication_classes = [ApplicationTokenAuthentication] queryset = OneTimeAuthenticationKey.objects.none() serializer_class = OneTimeAuthenticationKeySerializer + def create(self, request, *args, **kwargs): if type(request.auth) is not AuthorizedApplication: # Only specially-authorized applications are allowed to create @@ -505,14 +474,14 @@ def get_serializer_class(self): return ImportTaskSerializer def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous: + if self.request.user.is_anonymous(): return ImportTask.objects.none() else: return ImportTask.objects.filter( user=self.request.user).order_by('date_created') def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous: + if self.request.user.is_anonymous(): raise exceptions.NotAuthenticated() itask_data = { 'library': request.POST.get('library') not in ['false', False], @@ -551,7 +520,7 @@ class ExportTaskViewSet(NoUpdateModelViewSet): lookup_field = 'uid' def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous: + if self.request.user.is_anonymous(): return ExportTask.objects.none() queryset = ExportTask.objects.filter( @@ -581,7 +550,7 @@ def get_queryset(self, *args, **kwargs): return queryset def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous: + if self.request.user.is_anonymous(): raise exceptions.NotAuthenticated() # Read valid options from POST data @@ -648,7 +617,7 @@ def filter_queryset(self, queryset): else: user = self.request.user owned_snapshots = queryset.none() - if not user.is_anonymous: + if not user.is_anonymous(): owned_snapshots = queryset.filter(owner=user) return owned_snapshots | RelatedAssetPermissionsFilter( ).filter_queryset(self.request, queryset, view=self) @@ -769,40 +738,38 @@ def create(self, request, *args, **kwargs): :param request: :return: """ - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - - instance_id = request.data.get("instance_id") - if instance_id is None: - raise exceptions.ValidationError( - {'instance_id': _('This field is required.')}) - - instance = None + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") instance = asset.deployment.get_submission(instance_id) - except ValueError: - raise Http404 - # Check if instance really belongs to Asset. - if not (instance and - instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - raise Http404 - - if HookUtils.call_services(asset, instance_id): - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - else: - # call_services() refused to launch any task because this - # instance already has a `HookLog` - response_status_code = status.HTTP_409_CONFLICT + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) + "detail": _("An error has occurred when calling the external service. Please retry later.") } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR return Response(response, status=response_status_code) @@ -876,7 +843,7 @@ class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): Update current submission _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for editing._ + Instead, it returns the link where the instance can be opened for edition._
     GET /assets/{uid}/submissions/{id}/edit/
@@ -978,30 +945,16 @@ def edit(self, request, pk, *args, **kwargs):
         return Response(**json_response)
 
     def list(self, request, *args, **kwargs):
-        format_type = kwargs.get('format', request.GET.get('format', 'json'))
+        format_type = kwargs.get("format", request.GET.get("format", "json"))
         deployment = self._get_deployment()
-        filters = request.GET.dict()
-        # remove `format` from filters, it's redundant.
-        filters.pop('format', None)
-        # Do not allow requests to retrieve more than `SUBMISSION_LIST_LIMIT`
-        # submissions at one time
-        limit = filters.get('limit', settings.SUBMISSION_LIST_LIMIT)
-        try:
-            limit = int(limit)
-        except ValueError:
-            raise exceptions.ValidationError(
-                {'limit': _('A valid integer is required')}
-            )
-        filters['limit'] = min(limit, settings.SUBMISSION_LIST_LIMIT)
+        filters = self._filter_mongo_query(request)
         submissions = deployment.get_submissions(format_type=format_type, **filters)
         return Response(list(submissions))
 
     def retrieve(self, request, pk, *args, **kwargs):
-        format_type = kwargs.get('format', request.GET.get('format', 'json'))
+        format_type = kwargs.get("format", request.GET.get("format", "json"))
         deployment = self._get_deployment()
-        filters = request.GET.dict()
-        # remove `format` from filters, it's redundant.
-        filters.pop('format', None)
+        filters = self._filter_mongo_query(request)
         submission = deployment.get_submission(pk, format_type=format_type, **filters)
         if not submission:
             raise Http404
@@ -1011,23 +964,23 @@ def retrieve(self, request, pk, *args, **kwargs):
     def validation_status(self, request, pk, *args, **kwargs):
         deployment = self._get_deployment()
         if request.method == "PATCH":
-            json_response = deployment.set_validation_status(pk, request.data, request.user)
+            json_response = deployment.set_validate_status(pk, request.data, request.user)
         else:
-            json_response = deployment.get_validation_status(pk, request.GET, request.user)
+            json_response = deployment.get_validate_status(pk, request.GET, request.user)
 
         return Response(**json_response)
 
     @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
     def validation_statuses(self, request, *args, **kwargs):
         deployment = self._get_deployment()
-        json_response = deployment.set_validation_statuses(request.data, request.user)
+        json_response = deployment.set_validate_statuses(request.data, request.user)
 
         return Response(**json_response)
 
     def _filter_mongo_query(self, request):
         """
         Build filters to pass to Mongo query.
-        Acts like Django `filter_backend`
+        Acts like Django `filter_backends`
 
         :param request:
         :return: dict
@@ -1038,9 +991,11 @@ def _filter_mongo_query(self, request):
         if request.method == "GET":
             filters = request.GET.dict()
 
-        if asset.has_perm(request.user, "supervisor_view_submissions"):
-            pass
+        submitted_by = asset.get_usernames_for_restricted_perm(request.user)
 
+        filters.update({
+            "submitted_by": submitted_by
+        })
         return filters
 
 
@@ -1367,7 +1322,7 @@ def hash(self, request):
         :return: JSON
         """
         user = self.request.user
-        if user.is_anonymous:
+        if user.is_anonymous():
             raise exceptions.NotAuthenticated()
         else:
             accessible_assets = get_objects_for_user(
@@ -1540,7 +1495,7 @@ def perform_create(self, serializer):
         # django.contrib.auth.models.AnonymousUser object doesn't work for
         # queries.
         user = self.request.user
-        if user.is_anonymous:
+        if user.is_anonymous():
             user = get_anonymous_user()
         serializer.save(owner=user)
 
@@ -1603,7 +1558,7 @@ def get_queryset(self):
         # Check if the user is anonymous. The
         # django.contrib.auth.models.AnonymousUser object doesn't work for
         # queries.
-        if user.is_anonymous:
+        if user.is_anonymous():
             user = get_anonymous_user()
         criteria = {'user': user}
         if 'collection__uid' in self.request.query_params:
@@ -1621,7 +1576,7 @@ def _which_user(self, request):
         Determine the user from `request`, allowing superusers to specify
         another user by passing the `username` query parameter
         '''
-        if request.user.is_anonymous:
+        if request.user.is_anonymous():
             raise exceptions.NotAuthenticated()
 
         if 'username' in request.query_params:
@@ -1662,9 +1617,7 @@ def delete(self, request, *args, **kwargs):
 
 
 class EnvironmentView(APIView):
-    """
-    GET-only view for certain server-provided configuration data
-    """
+    ''' GET-only view for certain server-provided configuration data '''
 
     CONFIGS_TO_EXPOSE = [
         'TERMS_OF_SERVICE_URL',
@@ -1675,18 +1628,11 @@ class EnvironmentView(APIView):
     ]
 
     def get(self, request, *args, **kwargs):
-        """
+        '''
         Return the lowercased key and value of each setting in
-        `CONFIGS_TO_EXPOSE`, along with the static lists of sectors, countries,
-        all known languages, and languages for which the interface has
-        translations.
-        """
-        data = {
+        `CONFIGS_TO_EXPOSE`
+        '''
+        return Response({
             key.lower(): getattr(constance.config, key)
                 for key in self.CONFIGS_TO_EXPOSE
-        }
-        data['available_sectors'] = SECTORS
-        data['available_countries'] = COUNTRIES
-        data['all_languages'] = LANGUAGES
-        data['interface_languages'] = settings.LANGUAGES
-        return Response(data)
+        })
diff --git a/kpi/views/asset_file.py b/kpi/views/asset_file.py
new file mode 100644
index 0000000000..1b9a7c435b
--- /dev/null
+++ b/kpi/views/asset_file.py
@@ -0,0 +1,1638 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from distutils.util import strtobool
+from itertools import chain
+import copy
+from hashlib import md5
+import json
+import base64
+import datetime
+
+from django.contrib.auth import login
+from django.contrib.auth.models import User
+from django.contrib.auth.decorators import login_required
+from django.db import transaction
+from django.db.models import Q, Count
+from django.forms import model_to_dict
+from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
+from django.utils.http import is_safe_url
+from django.shortcuts import get_object_or_404, resolve_url
+from django.template.response import TemplateResponse
+from django.conf import settings
+from django.views.decorators.http import require_POST
+from django.views.decorators.csrf import csrf_exempt
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import (
+    viewsets,
+    mixins,
+    renderers,
+    status,
+    exceptions,
+)
+from rest_framework.decorators import api_view
+from rest_framework.decorators import renderer_classes
+from rest_framework.decorators import detail_route, list_route
+from rest_framework.decorators import authentication_classes
+from rest_framework.parsers import MultiPartParser
+from rest_framework.response import Response
+from rest_framework.reverse import reverse
+from rest_framework.authtoken.models import Token
+from rest_framework.views import APIView
+from rest_framework_extensions.mixins import NestedViewSetMixin
+
+import constance
+from taggit.models import Tag
+from private_storage.views import PrivateStorageDetailView
+
+from .filters import KpiAssignedObjectPermissionsFilter
+from .filters import AssetOwnerFilterBackend
+from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter
+from .filters import SearchFilter
+from .highlighters import highlight_xform
+from hub.models import SitewideMessage
+from .models import (
+    Collection,
+    Asset,
+    AssetVersion,
+    AssetSnapshot,
+    AssetFile,
+    ImportTask,
+    ExportTask,
+    ObjectPermission,
+    AuthorizedApplication,
+    OneTimeAuthenticationKey,
+    UserCollectionSubscription,
+    )
+from .models.object_permission import get_anonymous_user, get_objects_for_user
+from .models.authorized_application import ApplicationTokenAuthentication
+from .models.import_export_task import _resolve_url_to_asset_or_collection
+from .model_utils import disable_auto_field_update, remove_string_prefix
+from .permissions import (
+    IsOwnerOrReadOnly,
+    PostMappedToChangePermission,
+    get_perm_name,
+    SubmissionPermission
+)
+from .renderers import (
+    AssetJsonRenderer,
+    SSJsonRenderer,
+    XFormRenderer,
+    XMLRenderer,
+    SubmissionXMLRenderer,
+    XlsRenderer,)
+from .serializers import (
+    AssetSerializer, AssetListSerializer,
+    AssetVersionListSerializer,
+    AssetVersionSerializer,
+    AssetFileSerializer,
+    AssetSnapshotSerializer,
+    SitewideMessageSerializer,
+    CollectionSerializer, CollectionListSerializer,
+    UserSerializer,
+    CurrentUserSerializer, CreateUserSerializer,
+    TagSerializer, TagListSerializer,
+    ImportTaskSerializer, ImportTaskListSerializer,
+    ExportTaskSerializer,
+    ObjectPermissionSerializer,
+    AuthorizedApplicationUserSerializer,
+    OneTimeAuthenticationKeySerializer,
+    DeploymentSerializer,
+    UserCollectionSubscriptionSerializer,)
+from .utils.gravatar_url import gravatar_url
+from .utils.kobo_to_xlsform import to_xlsform_structure
+from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable
+from .tasks import import_in_background, export_in_background
+from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \
+    COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \
+    ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \
+    PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \
+    PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS
+from .deployment_backends.backends import DEPLOYMENT_BACKENDS
+
+from kobo.apps.hook.utils import HookUtils
+from kpi.exceptions import BadAssetTypeException
+from kpi.utils.log import logging
+
+
+@login_required
+def home(request):
+    return TemplateResponse(request, "index.html")
+
+
+def browser_tests(request):
+    return TemplateResponse(request, "browser_tests.html")
+
+
+class NoUpdateModelViewSet(
+    mixins.CreateModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.ListModelMixin,
+    viewsets.GenericViewSet
+):
+    '''
+    Inherit from everything that ModelViewSet does, except for
+    UpdateModelMixin.
+    '''
+    pass
+
+
+class ObjectPermissionViewSet(NoUpdateModelViewSet):
+    queryset = ObjectPermission.objects.all()
+    serializer_class = ObjectPermissionSerializer
+    lookup_field = 'uid'
+    filter_backends = (KpiAssignedObjectPermissionsFilter, )
+
+    def _requesting_user_can_share(self, affected_object, codename):
+        r"""
+            Return `True` if `self.request.user` is allowed to grant and revoke
+            `codename` on `affected_object`. For `Collection`, this is always
+            the same as checking that `self.request.user` has the
+            `share_collection` permission on `affected_object`. For `Asset`,
+            the result is determined by either `share_asset` or
+            `share_submissions`, depending on the `codename`.
+            :type affected_object: :py:class:Asset or :py:class:Collection
+            :type codename: str
+            :rtype bool
+        """
+        model_name = affected_object._meta.model_name
+        if model_name == 'asset' and codename.endswith('_submissions'):
+            share_permission = PERM_SHARE_SUBMISSIONS
+        else:
+            share_permission = 'share_{}'.format(model_name)
+        return affected_object.has_perm(self.request.user, share_permission)
+
+    def perform_create(self, serializer):
+        # Make sure the requesting user has the share_ permission on
+        # the affected object
+        with transaction.atomic():
+            affected_object = serializer.validated_data['content_object']
+            codename = serializer.validated_data['permission'].codename
+            if not self._requesting_user_can_share(affected_object, codename):
+                raise exceptions.PermissionDenied()
+            serializer.save()
+
+    def perform_destroy(self, instance):
+        # Only directly-applied permissions may be modified; forbid deleting
+        # permissions inherited from ancestors
+        if instance.inherited:
+            raise exceptions.MethodNotAllowed(
+                self.request.method,
+                detail='Cannot delete inherited permissions.'
+            )
+        # Make sure the requesting user has the share_ permission on
+        # the affected object
+        with transaction.atomic():
+            affected_object = instance.content_object
+            codename = instance.permission.codename
+            if not self._requesting_user_can_share(affected_object, codename):
+                raise exceptions.PermissionDenied()
+            instance.content_object.remove_perm(
+                instance.user,
+                instance.permission.codename
+            )
+
+
+class CollectionViewSet(viewsets.ModelViewSet):
+    # Filtering handled by KpiObjectPermissionsFilter.filter_queryset()
+    queryset = Collection.objects.select_related(
+        'owner', 'parent'
+    ).prefetch_related(
+        'permissions',
+        'permissions__permission',
+        'permissions__user',
+        'permissions__content_object',
+        'usercollectionsubscription_set',
+    ).all().order_by('-date_modified')
+    serializer_class = CollectionSerializer
+    permission_classes = (IsOwnerOrReadOnly,)
+    filter_backends = (KpiObjectPermissionsFilter, SearchFilter)
+    lookup_field = 'uid'
+
+    def _clone(self):
+        # Clone an existing collection.
+        original_uid = self.request.data[CLONE_ARG_NAME]
+        original_collection = get_object_or_404(Collection, uid=original_uid)
+        view_perm = get_perm_name('view', original_collection)
+        if not self.request.user.has_perm(view_perm, original_collection):
+            raise Http404
+        else:
+            # Copy the essential data from the original collection.
+            original_data= model_to_dict(original_collection)
+            cloned_data= {keep_field: original_data[keep_field]
+                          for keep_field in COLLECTION_CLONE_FIELDS}
+            if original_collection.tag_string:
+                cloned_data['tag_string']= original_collection.tag_string
+
+            # Pull any additionally provided parameters/overrides from the
+            # request.
+            for param in self.request.data:
+                cloned_data[param] = self.request.data[param]
+            serializer = self.get_serializer(data=cloned_data)
+            serializer.is_valid(raise_exception=True)
+            self.perform_create(serializer)
+
+            headers = self.get_success_headers(serializer.data)
+            return Response(serializer.data, status=status.HTTP_201_CREATED,
+                            headers=headers)
+
+    def create(self, request, *args, **kwargs):
+        if CLONE_ARG_NAME not in request.data:
+            return super(CollectionViewSet, self).create(request, *args,
+                                                         **kwargs)
+        else:
+            return self._clone()
+
+    def perform_create(self, serializer):
+        serializer.save(owner=self.request.user)
+
+    def perform_update(self, serializer, *args, **kwargs):
+        ''' Only the owner is allowed to change `discoverable_when_public` '''
+        original_collection = self.get_object()
+        if (self.request.user != original_collection.owner and
+                'discoverable_when_public' in serializer.validated_data and
+                (serializer.validated_data['discoverable_when_public'] !=
+                    original_collection.discoverable_when_public)
+        ):
+            raise exceptions.PermissionDenied()
+
+        # Some fields shouldn't affect the modification date
+        FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set((
+            'discoverable_when_public',
+        ))
+        changed_fields = set()
+        for k, v in serializer.validated_data.iteritems():
+            if getattr(original_collection, k) != v:
+                changed_fields.add(k)
+        if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE):
+            with disable_auto_field_update(Collection, 'date_modified'):
+                return super(CollectionViewSet, self).perform_update(
+                    serializer, *args, **kwargs)
+
+        return super(CollectionViewSet, self).perform_update(
+                serializer, *args, **kwargs)
+
+    def perform_destroy(self, instance):
+        instance.delete_with_deferred_indexing()
+
+    def get_serializer_class(self):
+        if self.action == 'list':
+            return CollectionListSerializer
+        else:
+            return CollectionSerializer
+
+
+class TagViewSet(viewsets.ReadOnlyModelViewSet):
+    queryset = Tag.objects.all()
+    serializer_class = TagSerializer
+    lookup_field = 'taguid__uid'
+    filter_backends = (SearchFilter,)
+
+    def get_queryset(self, *args, **kwargs):
+        user = self.request.user
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        if user.is_anonymous():
+            user = get_anonymous_user()
+
+        def _get_tags_on_items(content_type_name, avail_items):
+            '''
+            return all ids of tags which are tagged to items of the given
+            content_type
+            '''
+            same_content_type = Q(
+                taggit_taggeditem_items__content_type__model=content_type_name)
+            same_id = Q(
+                taggit_taggeditem_items__object_id__in=avail_items.
+                values_list('id'))
+            return Tag.objects.filter(same_content_type & same_id).distinct().\
+                values_list('id', flat=True)
+
+        accessible_collections = get_objects_for_user(
+            user, PERM_VIEW_COLLECTION, Collection).only('pk')
+        accessible_assets = get_objects_for_user(
+            user, PERM_VIEW_ASSET, Asset).only('pk')
+        all_tag_ids = list(chain(
+            _get_tags_on_items('collection', accessible_collections),
+            _get_tags_on_items('asset', accessible_assets),
+        ))
+
+        return Tag.objects.filter(id__in=all_tag_ids).distinct()
+
+    def get_serializer_class(self):
+        if self.action == 'list':
+            return TagListSerializer
+        else:
+            return TagSerializer
+
+
+class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
+    """
+    This viewset provides only the `detail` action; `list` is *not* provided to
+    avoid disclosing every username in the database
+    """
+    queryset = User.objects.all()
+    serializer_class = UserSerializer
+    lookup_field = 'username'
+
+    def __init__(self, *args, **kwargs):
+        super(UserViewSet, self).__init__(*args, **kwargs)
+        self.authentication_classes += [ApplicationTokenAuthentication]
+
+    def list(self, request, *args, **kwargs):
+        raise exceptions.PermissionDenied()
+
+
+class CurrentUserViewSet(viewsets.ModelViewSet):
+    queryset = User.objects.none()
+    serializer_class = CurrentUserSerializer
+
+    def get_object(self):
+        return self.request.user
+
+
+class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin,
+                                       viewsets.GenericViewSet):
+    authentication_classes = [ApplicationTokenAuthentication]
+    queryset = User.objects.all()
+    serializer_class = CreateUserSerializer
+    lookup_field = 'username'
+    def create(self, request, *args, **kwargs):
+        if type(request.auth) is not AuthorizedApplication:
+            # Only specially-authorized applications are allowed to create
+            # users via this endpoint
+            raise exceptions.PermissionDenied()
+        return super(AuthorizedApplicationUserViewSet, self).create(
+            request, *args, **kwargs)
+
+
+@api_view(['POST'])
+@authentication_classes([ApplicationTokenAuthentication])
+def authorized_application_authenticate_user(request):
+    ''' Returns a user-level API token when given a valid username and
+    password. The request header must include an authorized application key '''
+    if type(request.auth) is not AuthorizedApplication:
+        # Only specially-authorized applications are allowed to authenticate
+        # users this way
+        raise exceptions.PermissionDenied()
+    serializer = AuthorizedApplicationUserSerializer(data=request.data)
+    serializer.is_valid(raise_exception=True)
+    username = serializer.validated_data['username']
+    password = serializer.validated_data['password']
+    try:
+        user = User.objects.get(username=username)
+    except User.DoesNotExist:
+        raise exceptions.PermissionDenied()
+    if not user.is_active or not user.check_password(password):
+        raise exceptions.PermissionDenied()
+    token = Token.objects.get_or_create(user=user)[0]
+    response_data = {'token': token.key}
+    user_attributes_to_return = (
+        'username',
+        'first_name',
+        'last_name',
+        'email',
+        'is_staff',
+        'is_active',
+        'is_superuser',
+        'last_login',
+        'date_joined'
+    )
+    for attribute in user_attributes_to_return:
+        response_data[attribute] = getattr(user, attribute)
+    return Response(response_data)
+
+
+class OneTimeAuthenticationKeyViewSet(
+        mixins.CreateModelMixin,
+        viewsets.GenericViewSet
+):
+    authentication_classes = [ApplicationTokenAuthentication]
+    queryset = OneTimeAuthenticationKey.objects.none()
+    serializer_class = OneTimeAuthenticationKeySerializer
+
+    def create(self, request, *args, **kwargs):
+        if type(request.auth) is not AuthorizedApplication:
+            # Only specially-authorized applications are allowed to create
+            # one-time authentication keys via this endpoint
+            raise exceptions.PermissionDenied()
+        return super(OneTimeAuthenticationKeyViewSet, self).create(
+            request, *args, **kwargs)
+
+
+@require_POST
+@csrf_exempt
+def one_time_login(request):
+    ''' If the request provides a key that matches a OneTimeAuthenticationKey
+    object, log in the User specified in that object and redirect to the
+    location specified in the 'next' parameter '''
+    try:
+        key = request.POST['key']
+    except KeyError:
+        return HttpResponseBadRequest(_('No key provided'))
+    try:
+        next_ = request.GET['next']
+    except KeyError:
+        next_ = None
+    if not next_ or not is_safe_url(url=next_, host=request.get_host()):
+        next_ = resolve_url(settings.LOGIN_REDIRECT_URL)
+    # Clean out all expired keys, just to keep the database tidier
+    OneTimeAuthenticationKey.objects.filter(
+        expiry__lt=datetime.datetime.now()).delete()
+    with transaction.atomic():
+        try:
+            otak = OneTimeAuthenticationKey.objects.get(
+                key=key,
+                expiry__gte=datetime.datetime.now()
+            )
+        except OneTimeAuthenticationKey.DoesNotExist:
+            return HttpResponseBadRequest(_('Invalid or expired key'))
+        # Nevermore
+        otak.delete()
+    # The request included a valid one-time key. Log in the associated user
+    user = otak.user
+    user.backend = settings.AUTHENTICATION_BACKENDS[0]
+    login(request, user)
+    return HttpResponseRedirect(next_)
+
+
+class XlsFormParser(MultiPartParser):
+    pass
+
+
+class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet):
+    queryset = ImportTask.objects.all()
+    serializer_class = ImportTaskSerializer
+    lookup_field = 'uid'
+
+    def get_serializer_class(self):
+        if self.action == 'list':
+            return ImportTaskListSerializer
+        else:
+            return ImportTaskSerializer
+
+    def get_queryset(self, *args, **kwargs):
+        if self.request.user.is_anonymous():
+            return ImportTask.objects.none()
+        else:
+            return ImportTask.objects.filter(
+                        user=self.request.user).order_by('date_created')
+
+    def create(self, request, *args, **kwargs):
+        if self.request.user.is_anonymous():
+            raise exceptions.NotAuthenticated()
+        itask_data = {
+            'library': request.POST.get('library') not in ['false', False],
+            # NOTE: 'filename' here comes from 'name' (!) in the POST data
+            'filename': request.POST.get('name', None),
+            'destination': request.POST.get('destination', None),
+        }
+        if 'base64Encoded' in request.POST:
+            encoded_str = request.POST['base64Encoded']
+            encoded_substr = encoded_str[encoded_str.index('base64') + 7:]
+            itask_data['base64Encoded'] = encoded_substr
+        elif 'file' in request.data:
+            encoded_xls = base64.b64encode(request.data['file'].read())
+            itask_data['base64Encoded'] = encoded_xls
+            if 'filename' not in itask_data:
+                itask_data['filename'] = request.data['file'].name
+        elif 'url' in request.POST:
+            itask_data['single_xls_url'] = request.POST['url']
+        import_task = ImportTask.objects.create(user=request.user,
+                                                data=itask_data)
+        # Have Celery run the import in the background
+        import_in_background.delay(import_task_uid=import_task.uid)
+        return Response({
+            'uid': import_task.uid,
+            'url': reverse(
+                'importtask-detail',
+                kwargs={'uid': import_task.uid},
+                request=request),
+            'status': ImportTask.PROCESSING
+        }, status.HTTP_201_CREATED)
+
+
+class ExportTaskViewSet(NoUpdateModelViewSet):
+    queryset = ExportTask.objects.all()
+    serializer_class = ExportTaskSerializer
+    lookup_field = 'uid'
+
+    def get_queryset(self, *args, **kwargs):
+        if self.request.user.is_anonymous():
+            return ExportTask.objects.none()
+
+        queryset = ExportTask.objects.filter(
+            user=self.request.user).order_by('date_created')
+
+        # Ultra-basic filtering by:
+        # * source URL or UID if `q=source:[URL|UID]` was provided;
+        # * comma-separated list of `ExportTask` UIDs if
+        #   `q=uid__in:[UID],[UID],...` was provided
+        q = self.request.query_params.get('q', False)
+        if not q:
+            # No filter requested
+            return queryset
+        if q.startswith('source:'):
+            q = remove_string_prefix(q, 'source:')
+            # This is exceedingly crude... but support for querying inside
+            # JSONField not available until Django 1.9
+            queryset = queryset.filter(data__contains=q)
+        elif q.startswith('uid__in:'):
+            q = remove_string_prefix(q, 'uid__in:')
+            uids = [uid.strip() for uid in q.split(',')]
+            queryset = queryset.filter(uid__in=uids)
+        else:
+            # Filter requested that we don't understand; make it obvious by
+            # returning nothing
+            return ExportTask.objects.none()
+        return queryset
+
+    def create(self, request, *args, **kwargs):
+        if self.request.user.is_anonymous():
+            raise exceptions.NotAuthenticated()
+
+        # Read valid options from POST data
+        valid_options = (
+            'type',
+            'source',
+            'group_sep',
+            'lang',
+            'hierarchy_in_labels',
+            'fields_from_all_versions',
+        )
+        task_data = {}
+        for opt in valid_options:
+            opt_val = request.POST.get(opt, None)
+            if opt_val is not None:
+                task_data[opt] = opt_val
+        # Complain if no source was specified
+        if not task_data.get('source', False):
+            raise exceptions.ValidationError(
+                {'source': 'This field is required.'})
+        # Get the source object
+        source_type, source = _resolve_url_to_asset_or_collection(
+            task_data['source'])
+        # Complain if it's not an Asset
+        if source_type != 'asset':
+            raise exceptions.ValidationError(
+                {'source': 'This field must specify an asset.'})
+        # Complain if it's not deployed
+        if not source.has_deployment:
+            raise exceptions.ValidationError(
+                {'source': 'The specified asset must be deployed.'})
+        # Create a new export task
+        export_task = ExportTask.objects.create(user=request.user,
+                                                data=task_data)
+        # Have Celery run the export in the background
+        export_in_background.delay(export_task_uid=export_task.uid)
+        return Response({
+            'uid': export_task.uid,
+            'url': reverse(
+                'exporttask-detail',
+                kwargs={'uid': export_task.uid},
+                request=request),
+            'status': ExportTask.PROCESSING
+        }, status.HTTP_201_CREATED)
+
+
+class AssetSnapshotViewSet(NoUpdateModelViewSet):
+    serializer_class = AssetSnapshotSerializer
+    lookup_field = 'uid'
+    queryset = AssetSnapshot.objects.all()
+
+    renderer_classes = NoUpdateModelViewSet.renderer_classes + [
+        XMLRenderer,
+    ]
+
+    def filter_queryset(self, queryset):
+        if (self.action == 'retrieve' and
+                self.request.accepted_renderer.format == 'xml'):
+            # The XML renderer is totally public and serves anyone, so
+            # /asset_snapshot/valid_uid.xml is world-readable, even though
+            # /asset_snapshot/valid_uid/ requires ownership. Return the
+            # queryset unfiltered
+            return queryset
+        else:
+            user = self.request.user
+            owned_snapshots = queryset.none()
+            if not user.is_anonymous():
+                owned_snapshots = queryset.filter(owner=user)
+            return owned_snapshots | RelatedAssetPermissionsFilter(
+                ).filter_queryset(self.request, queryset, view=self)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        '''
+        This route will render the XForm into syntax-highlighted HTML.
+        It is useful for debugging pyxform transformations
+        '''
+        snapshot = self.get_object()
+        response_data = copy.copy(snapshot.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if snapshot.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(snapshot.xml,
+                                                                 **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def preview(self, request, *args, **kwargs):
+        snapshot = self.get_object()
+        if snapshot.details.get('status') == 'success':
+            preview_url = "{}{}?form={}".format(
+                              settings.ENKETO_SERVER,
+                              settings.ENKETO_PREVIEW_URI,
+                              reverse(viewname='assetsnapshot-detail',
+                                      format='xml',
+                                      kwargs={'uid': snapshot.uid},
+                                      request=request,
+                                      ),
+                            )
+            return HttpResponseRedirect(preview_url)
+        else:
+            response_data = copy.copy(snapshot.details)
+            return Response(response_data, template_name='preview_error.html')
+
+
+class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet):
+    model = AssetFile
+    lookup_field = 'uid'
+    filter_backends = (RelatedAssetPermissionsFilter,)
+    serializer_class = AssetFileSerializer
+
+    def get_queryset(self):
+        _asset_uid = self.get_parents_query_dict()['asset']
+        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
+        return _queryset
+
+    def perform_create(self, serializer):
+        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
+        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
+            raise exceptions.PermissionDenied()
+        serializer.save(
+            asset=asset,
+            user=self.request.user
+        )
+
+    def perform_destroy(self, *args, **kwargs):
+        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
+        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
+            raise exceptions.PermissionDenied()
+        return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs)
+
+    class PrivateContentView(PrivateStorageDetailView):
+        model = AssetFile
+        model_file_field = 'content'
+        def can_access_file(self, private_file):
+            return private_file.request.user.has_perm(
+                PERM_VIEW_ASSET, private_file.parent_object.asset)
+
+    @detail_route(methods=['get'])
+    def content(self, *args, **kwargs):
+        view = self.PrivateContentView.as_view(
+            model=AssetFile,
+            slug_url_kwarg='uid',
+            slug_field='uid',
+            model_file_field='content'
+        )
+        af = self.get_object()
+        # TODO: simply redirect if external storage with expiring tokens (e.g.
+        # Amazon S3) is used?
+        #   return HttpResponseRedirect(af.content.url)
+        return view(self.request, uid=af.uid)
+
+
+class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet):
+    """
+    ##
+    This endpoint is only used to trigger asset's hooks if any.
+
+    Tells the hooks to post an instance to external servers.
+    
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/asset_snapshot.py b/kpi/views/asset_snapshot.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/asset_snapshot.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/asset_version.py b/kpi/views/asset_version.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/asset_version.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/authorized_application_user.py b/kpi/views/authorized_application_user.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/authorized_application_user.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/collection.py b/kpi/views/collection.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/collection.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/current_user.py b/kpi/views/current_user.py index 791f81e0d0..df0c608045 100644 --- a/kpi/views/current_user.py +++ b/kpi/views/current_user.py @@ -1,8 +1,356 @@ +<<<<<<< HEAD # coding: utf-8 from django.contrib.auth.models import User from rest_framework import viewsets from kpi.serializers import CurrentUserSerializer +======= +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() +>>>>>>> WIP - splitted views.py in several files class CurrentUserViewSet(viewsets.ModelViewSet): @@ -11,3 +359,1291 @@ class CurrentUserViewSet(viewsets.ModelViewSet): def get_object(self): return self.request.user +<<<<<<< HEAD +======= + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) +>>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views/environment.py b/kpi/views/environment.py index a63d9356ae..c8fe46232c 100644 --- a/kpi/views/environment.py +++ b/kpi/views/environment.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 import constance from django.conf import settings @@ -13,12 +14,1636 @@ class EnvironmentView(APIView): GET-only view for certain server-provided configuration data """ +======= +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + +>>>>>>> WIP - splitted views.py in several files CONFIGS_TO_EXPOSE = [ 'TERMS_OF_SERVICE_URL', 'PRIVACY_POLICY_URL', 'SOURCE_CODE_URL', 'SUPPORT_URL', 'SUPPORT_EMAIL', +<<<<<<< HEAD 'COMMUNITY_URL', ] @@ -39,3 +1664,16 @@ def get(self, request, *args, **kwargs): data['interface_languages'] = settings.LANGUAGES data['submission_placeholder'] = SUBMISSION_PLACEHOLDER return Response(data) +======= + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) +>>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views/export_task.py b/kpi/views/export_task.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/export_task.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/hook_signal.py b/kpi/views/hook_signal.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/hook_signal.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/import_task.py b/kpi/views/import_task.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/import_task.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/no_update_model.py b/kpi/views/no_update_model.py index 490eb30456..dc0a606390 100644 --- a/kpi/views/no_update_model.py +++ b/kpi/views/no_update_model.py @@ -1,6 +1,133 @@ +<<<<<<< HEAD # coding: utf-8 from rest_framework import viewsets from rest_framework import mixins +======= +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") +>>>>>>> WIP - splitted views.py in several files class NoUpdateModelViewSet( @@ -10,8 +137,1516 @@ class NoUpdateModelViewSet( mixins.ListModelMixin, viewsets.GenericViewSet ): +<<<<<<< HEAD """ Inherit from everything that ModelViewSet does, except for UpdateModelMixin. """ pass +======= + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) +>>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views/object_permission.py b/kpi/views/object_permission.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/object_permission.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/one_time_authentication_key.py b/kpi/views/one_time_authentication_key.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/one_time_authentication_key.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/sitewide_message.py b/kpi/views/sitewide_message.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/sitewide_message.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/submission.py b/kpi/views/submission.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/submission.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/tag.py b/kpi/views/tag.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/tag.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/token.py b/kpi/views/token.py index 7a78cfa964..a36d3c82bb 100644 --- a/kpi/views/token.py +++ b/kpi/views/token.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from django.contrib.auth.models import User from django.db import transaction @@ -6,15 +7,1595 @@ from rest_framework.authtoken.models import Token from rest_framework.response import Response from rest_framework.views import APIView +======= +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) +>>>>>>> WIP - splitted views.py in several files class TokenView(APIView): def _which_user(self, request): +<<<<<<< HEAD """ Determine the user from `request`, allowing superusers to specify another user by passing the `username` query parameter """ if request.user.is_anonymous: +======= + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): +>>>>>>> WIP - splitted views.py in several files raise exceptions.NotAuthenticated() if 'username' in request.query_params: @@ -31,13 +1612,21 @@ def _which_user(self, request): return user def get(self, request, *args, **kwargs): +<<<<<<< HEAD """ Retrieve an existing token only """ +======= + ''' Retrieve an existing token only ''' +>>>>>>> WIP - splitted views.py in several files user = self._which_user(request) token = get_object_or_404(Token, user=user) return Response({'token': token.key}) def post(self, request, *args, **kwargs): +<<<<<<< HEAD """ Return a token, creating a new one if none exists """ +======= + ''' Return a token, creating a new one if none exists ''' +>>>>>>> WIP - splitted views.py in several files user = self._which_user(request) token, created = Token.objects.get_or_create(user=user) return Response( @@ -46,9 +1635,38 @@ def post(self, request, *args, **kwargs): ) def delete(self, request, *args, **kwargs): +<<<<<<< HEAD """ Delete an existing token and do not generate a new one """ +======= + ''' Delete an existing token and do not generate a new one ''' +>>>>>>> WIP - splitted views.py in several files user = self._which_user(request) with transaction.atomic(): token = get_object_or_404(Token, user=user) token.delete() return Response({}, status=status.HTTP_204_NO_CONTENT) +<<<<<<< HEAD +======= + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) +>>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views/user.py b/kpi/views/user.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/user.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) diff --git a/kpi/views/user_collection_subscription.py b/kpi/views/user_collection_subscription.py new file mode 100644 index 0000000000..1b9a7c435b --- /dev/null +++ b/kpi/views/user_collection_subscription.py @@ -0,0 +1,1638 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +from distutils.util import strtobool +from itertools import chain +import copy +from hashlib import md5 +import json +import base64 +import datetime + +from django.contrib.auth import login +from django.contrib.auth.models import User +from django.contrib.auth.decorators import login_required +from django.db import transaction +from django.db.models import Q, Count +from django.forms import model_to_dict +from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect +from django.utils.http import is_safe_url +from django.shortcuts import get_object_or_404, resolve_url +from django.template.response import TemplateResponse +from django.conf import settings +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.utils.translation import ugettext_lazy as _ +from rest_framework import ( + viewsets, + mixins, + renderers, + status, + exceptions, +) +from rest_framework.decorators import api_view +from rest_framework.decorators import renderer_classes +from rest_framework.decorators import detail_route, list_route +from rest_framework.decorators import authentication_classes +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.authtoken.models import Token +from rest_framework.views import APIView +from rest_framework_extensions.mixins import NestedViewSetMixin + +import constance +from taggit.models import Tag +from private_storage.views import PrivateStorageDetailView + +from .filters import KpiAssignedObjectPermissionsFilter +from .filters import AssetOwnerFilterBackend +from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter +from .filters import SearchFilter +from .highlighters import highlight_xform +from hub.models import SitewideMessage +from .models import ( + Collection, + Asset, + AssetVersion, + AssetSnapshot, + AssetFile, + ImportTask, + ExportTask, + ObjectPermission, + AuthorizedApplication, + OneTimeAuthenticationKey, + UserCollectionSubscription, + ) +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.authorized_application import ApplicationTokenAuthentication +from .models.import_export_task import _resolve_url_to_asset_or_collection +from .model_utils import disable_auto_field_update, remove_string_prefix +from .permissions import ( + IsOwnerOrReadOnly, + PostMappedToChangePermission, + get_perm_name, + SubmissionPermission +) +from .renderers import ( + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XMLRenderer, + SubmissionXMLRenderer, + XlsRenderer,) +from .serializers import ( + AssetSerializer, AssetListSerializer, + AssetVersionListSerializer, + AssetVersionSerializer, + AssetFileSerializer, + AssetSnapshotSerializer, + SitewideMessageSerializer, + CollectionSerializer, CollectionListSerializer, + UserSerializer, + CurrentUserSerializer, CreateUserSerializer, + TagSerializer, TagListSerializer, + ImportTaskSerializer, ImportTaskListSerializer, + ExportTaskSerializer, + ObjectPermissionSerializer, + AuthorizedApplicationUserSerializer, + OneTimeAuthenticationKeySerializer, + DeploymentSerializer, + UserCollectionSubscriptionSerializer,) +from .utils.gravatar_url import gravatar_url +from .utils.kobo_to_xlsform import to_xlsform_structure +from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable +from .tasks import import_in_background, export_in_background +from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ + COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ + PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ + PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS +from .deployment_backends.backends import DEPLOYMENT_BACKENDS + +from kobo.apps.hook.utils import HookUtils +from kpi.exceptions import BadAssetTypeException +from kpi.utils.log import logging + + +@login_required +def home(request): + return TemplateResponse(request, "index.html") + + +def browser_tests(request): + return TemplateResponse(request, "browser_tests.html") + + +class NoUpdateModelViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + ''' + Inherit from everything that ModelViewSet does, except for + UpdateModelMixin. + ''' + pass + + +class ObjectPermissionViewSet(NoUpdateModelViewSet): + queryset = ObjectPermission.objects.all() + serializer_class = ObjectPermissionSerializer + lookup_field = 'uid' + filter_backends = (KpiAssignedObjectPermissionsFilter, ) + + def _requesting_user_can_share(self, affected_object, codename): + r""" + Return `True` if `self.request.user` is allowed to grant and revoke + `codename` on `affected_object`. For `Collection`, this is always + the same as checking that `self.request.user` has the + `share_collection` permission on `affected_object`. For `Asset`, + the result is determined by either `share_asset` or + `share_submissions`, depending on the `codename`. + :type affected_object: :py:class:Asset or :py:class:Collection + :type codename: str + :rtype bool + """ + model_name = affected_object._meta.model_name + if model_name == 'asset' and codename.endswith('_submissions'): + share_permission = PERM_SHARE_SUBMISSIONS + else: + share_permission = 'share_{}'.format(model_name) + return affected_object.has_perm(self.request.user, share_permission) + + def perform_create(self, serializer): + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = serializer.validated_data['content_object'] + codename = serializer.validated_data['permission'].codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + serializer.save() + + def perform_destroy(self, instance): + # Only directly-applied permissions may be modified; forbid deleting + # permissions inherited from ancestors + if instance.inherited: + raise exceptions.MethodNotAllowed( + self.request.method, + detail='Cannot delete inherited permissions.' + ) + # Make sure the requesting user has the share_ permission on + # the affected object + with transaction.atomic(): + affected_object = instance.content_object + codename = instance.permission.codename + if not self._requesting_user_can_share(affected_object, codename): + raise exceptions.PermissionDenied() + instance.content_object.remove_perm( + instance.user, + instance.permission.codename + ) + + +class CollectionViewSet(viewsets.ModelViewSet): + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Collection.objects.select_related( + 'owner', 'parent' + ).prefetch_related( + 'permissions', + 'permissions__permission', + 'permissions__user', + 'permissions__content_object', + 'usercollectionsubscription_set', + ).all().order_by('-date_modified') + serializer_class = CollectionSerializer + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + lookup_field = 'uid' + + def _clone(self): + # Clone an existing collection. + original_uid = self.request.data[CLONE_ARG_NAME] + original_collection = get_object_or_404(Collection, uid=original_uid) + view_perm = get_perm_name('view', original_collection) + if not self.request.user.has_perm(view_perm, original_collection): + raise Http404 + else: + # Copy the essential data from the original collection. + original_data= model_to_dict(original_collection) + cloned_data= {keep_field: original_data[keep_field] + for keep_field in COLLECTION_CLONE_FIELDS} + if original_collection.tag_string: + cloned_data['tag_string']= original_collection.tag_string + + # Pull any additionally provided parameters/overrides from the + # request. + for param in self.request.data: + cloned_data[param] = self.request.data[param] + serializer = self.get_serializer(data=cloned_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME not in request.data: + return super(CollectionViewSet, self).create(request, *args, + **kwargs) + else: + return self._clone() + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def perform_update(self, serializer, *args, **kwargs): + ''' Only the owner is allowed to change `discoverable_when_public` ''' + original_collection = self.get_object() + if (self.request.user != original_collection.owner and + 'discoverable_when_public' in serializer.validated_data and + (serializer.validated_data['discoverable_when_public'] != + original_collection.discoverable_when_public) + ): + raise exceptions.PermissionDenied() + + # Some fields shouldn't affect the modification date + FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( + 'discoverable_when_public', + )) + changed_fields = set() + for k, v in serializer.validated_data.iteritems(): + if getattr(original_collection, k) != v: + changed_fields.add(k) + if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): + with disable_auto_field_update(Collection, 'date_modified'): + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + return super(CollectionViewSet, self).perform_update( + serializer, *args, **kwargs) + + def perform_destroy(self, instance): + instance.delete_with_deferred_indexing() + + def get_serializer_class(self): + if self.action == 'list': + return CollectionListSerializer + else: + return CollectionSerializer + + +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + lookup_field = 'taguid__uid' + filter_backends = (SearchFilter,) + + def get_queryset(self, *args, **kwargs): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + + def _get_tags_on_items(content_type_name, avail_items): + ''' + return all ids of tags which are tagged to items of the given + content_type + ''' + same_content_type = Q( + taggit_taggeditem_items__content_type__model=content_type_name) + same_id = Q( + taggit_taggeditem_items__object_id__in=avail_items. + values_list('id')) + return Tag.objects.filter(same_content_type & same_id).distinct().\ + values_list('id', flat=True) + + accessible_collections = get_objects_for_user( + user, PERM_VIEW_COLLECTION, Collection).only('pk') + accessible_assets = get_objects_for_user( + user, PERM_VIEW_ASSET, Asset).only('pk') + all_tag_ids = list(chain( + _get_tags_on_items('collection', accessible_collections), + _get_tags_on_items('asset', accessible_assets), + )) + + return Tag.objects.filter(id__in=all_tag_ids).distinct() + + def get_serializer_class(self): + if self.action == 'list': + return TagListSerializer + else: + return TagSerializer + + +class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): + """ + This viewset provides only the `detail` action; `list` is *not* provided to + avoid disclosing every username in the database + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + def __init__(self, *args, **kwargs): + super(UserViewSet, self).__init__(*args, **kwargs) + self.authentication_classes += [ApplicationTokenAuthentication] + + def list(self, request, *args, **kwargs): + raise exceptions.PermissionDenied() + + +class CurrentUserViewSet(viewsets.ModelViewSet): + queryset = User.objects.none() + serializer_class = CurrentUserSerializer + + def get_object(self): + return self.request.user + + +class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, + viewsets.GenericViewSet): + authentication_classes = [ApplicationTokenAuthentication] + queryset = User.objects.all() + serializer_class = CreateUserSerializer + lookup_field = 'username' + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # users via this endpoint + raise exceptions.PermissionDenied() + return super(AuthorizedApplicationUserViewSet, self).create( + request, *args, **kwargs) + + +@api_view(['POST']) +@authentication_classes([ApplicationTokenAuthentication]) +def authorized_application_authenticate_user(request): + ''' Returns a user-level API token when given a valid username and + password. The request header must include an authorized application key ''' + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to authenticate + # users this way + raise exceptions.PermissionDenied() + serializer = AuthorizedApplicationUserSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + username = serializer.validated_data['username'] + password = serializer.validated_data['password'] + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise exceptions.PermissionDenied() + if not user.is_active or not user.check_password(password): + raise exceptions.PermissionDenied() + token = Token.objects.get_or_create(user=user)[0] + response_data = {'token': token.key} + user_attributes_to_return = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'is_staff', + 'is_active', + 'is_superuser', + 'last_login', + 'date_joined' + ) + for attribute in user_attributes_to_return: + response_data[attribute] = getattr(user, attribute) + return Response(response_data) + + +class OneTimeAuthenticationKeyViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet +): + authentication_classes = [ApplicationTokenAuthentication] + queryset = OneTimeAuthenticationKey.objects.none() + serializer_class = OneTimeAuthenticationKeySerializer + + def create(self, request, *args, **kwargs): + if type(request.auth) is not AuthorizedApplication: + # Only specially-authorized applications are allowed to create + # one-time authentication keys via this endpoint + raise exceptions.PermissionDenied() + return super(OneTimeAuthenticationKeyViewSet, self).create( + request, *args, **kwargs) + + +@require_POST +@csrf_exempt +def one_time_login(request): + ''' If the request provides a key that matches a OneTimeAuthenticationKey + object, log in the User specified in that object and redirect to the + location specified in the 'next' parameter ''' + try: + key = request.POST['key'] + except KeyError: + return HttpResponseBadRequest(_('No key provided')) + try: + next_ = request.GET['next'] + except KeyError: + next_ = None + if not next_ or not is_safe_url(url=next_, host=request.get_host()): + next_ = resolve_url(settings.LOGIN_REDIRECT_URL) + # Clean out all expired keys, just to keep the database tidier + OneTimeAuthenticationKey.objects.filter( + expiry__lt=datetime.datetime.now()).delete() + with transaction.atomic(): + try: + otak = OneTimeAuthenticationKey.objects.get( + key=key, + expiry__gte=datetime.datetime.now() + ) + except OneTimeAuthenticationKey.DoesNotExist: + return HttpResponseBadRequest(_('Invalid or expired key')) + # Nevermore + otak.delete() + # The request included a valid one-time key. Log in the associated user + user = otak.user + user.backend = settings.AUTHENTICATION_BACKENDS[0] + login(request, user) + return HttpResponseRedirect(next_) + + +class XlsFormParser(MultiPartParser): + pass + + +class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): + queryset = ImportTask.objects.all() + serializer_class = ImportTaskSerializer + lookup_field = 'uid' + + def get_serializer_class(self): + if self.action == 'list': + return ImportTaskListSerializer + else: + return ImportTaskSerializer + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ImportTask.objects.none() + else: + return ImportTask.objects.filter( + user=self.request.user).order_by('date_created') + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + itask_data = { + 'library': request.POST.get('library') not in ['false', False], + # NOTE: 'filename' here comes from 'name' (!) in the POST data + 'filename': request.POST.get('name', None), + 'destination': request.POST.get('destination', None), + } + if 'base64Encoded' in request.POST: + encoded_str = request.POST['base64Encoded'] + encoded_substr = encoded_str[encoded_str.index('base64') + 7:] + itask_data['base64Encoded'] = encoded_substr + elif 'file' in request.data: + encoded_xls = base64.b64encode(request.data['file'].read()) + itask_data['base64Encoded'] = encoded_xls + if 'filename' not in itask_data: + itask_data['filename'] = request.data['file'].name + elif 'url' in request.POST: + itask_data['single_xls_url'] = request.POST['url'] + import_task = ImportTask.objects.create(user=request.user, + data=itask_data) + # Have Celery run the import in the background + import_in_background.delay(import_task_uid=import_task.uid) + return Response({ + 'uid': import_task.uid, + 'url': reverse( + 'importtask-detail', + kwargs={'uid': import_task.uid}, + request=request), + 'status': ImportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class ExportTaskViewSet(NoUpdateModelViewSet): + queryset = ExportTask.objects.all() + serializer_class = ExportTaskSerializer + lookup_field = 'uid' + + def get_queryset(self, *args, **kwargs): + if self.request.user.is_anonymous(): + return ExportTask.objects.none() + + queryset = ExportTask.objects.filter( + user=self.request.user).order_by('date_created') + + # Ultra-basic filtering by: + # * source URL or UID if `q=source:[URL|UID]` was provided; + # * comma-separated list of `ExportTask` UIDs if + # `q=uid__in:[UID],[UID],...` was provided + q = self.request.query_params.get('q', False) + if not q: + # No filter requested + return queryset + if q.startswith('source:'): + q = remove_string_prefix(q, 'source:') + # This is exceedingly crude... but support for querying inside + # JSONField not available until Django 1.9 + queryset = queryset.filter(data__contains=q) + elif q.startswith('uid__in:'): + q = remove_string_prefix(q, 'uid__in:') + uids = [uid.strip() for uid in q.split(',')] + queryset = queryset.filter(uid__in=uids) + else: + # Filter requested that we don't understand; make it obvious by + # returning nothing + return ExportTask.objects.none() + return queryset + + def create(self, request, *args, **kwargs): + if self.request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + # Read valid options from POST data + valid_options = ( + 'type', + 'source', + 'group_sep', + 'lang', + 'hierarchy_in_labels', + 'fields_from_all_versions', + ) + task_data = {} + for opt in valid_options: + opt_val = request.POST.get(opt, None) + if opt_val is not None: + task_data[opt] = opt_val + # Complain if no source was specified + if not task_data.get('source', False): + raise exceptions.ValidationError( + {'source': 'This field is required.'}) + # Get the source object + source_type, source = _resolve_url_to_asset_or_collection( + task_data['source']) + # Complain if it's not an Asset + if source_type != 'asset': + raise exceptions.ValidationError( + {'source': 'This field must specify an asset.'}) + # Complain if it's not deployed + if not source.has_deployment: + raise exceptions.ValidationError( + {'source': 'The specified asset must be deployed.'}) + # Create a new export task + export_task = ExportTask.objects.create(user=request.user, + data=task_data) + # Have Celery run the export in the background + export_in_background.delay(export_task_uid=export_task.uid) + return Response({ + 'uid': export_task.uid, + 'url': reverse( + 'exporttask-detail', + kwargs={'uid': export_task.uid}, + request=request), + 'status': ExportTask.PROCESSING + }, status.HTTP_201_CREATED) + + +class AssetSnapshotViewSet(NoUpdateModelViewSet): + serializer_class = AssetSnapshotSerializer + lookup_field = 'uid' + queryset = AssetSnapshot.objects.all() + + renderer_classes = NoUpdateModelViewSet.renderer_classes + [ + XMLRenderer, + ] + + def filter_queryset(self, queryset): + if (self.action == 'retrieve' and + self.request.accepted_renderer.format == 'xml'): + # The XML renderer is totally public and serves anyone, so + # /asset_snapshot/valid_uid.xml is world-readable, even though + # /asset_snapshot/valid_uid/ requires ownership. Return the + # queryset unfiltered + return queryset + else: + user = self.request.user + owned_snapshots = queryset.none() + if not user.is_anonymous(): + owned_snapshots = queryset.filter(owner=user) + return owned_snapshots | RelatedAssetPermissionsFilter( + ).filter_queryset(self.request, queryset, view=self) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def xform(self, request, *args, **kwargs): + ''' + This route will render the XForm into syntax-highlighted HTML. + It is useful for debugging pyxform transformations + ''' + snapshot = self.get_object() + response_data = copy.copy(snapshot.details) + options = { + 'linenos': True, + 'full': True, + } + if snapshot.xml != '': + response_data['highlighted_xform'] = highlight_xform(snapshot.xml, + **options) + return Response(response_data, template_name='highlighted_xform.html') + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def preview(self, request, *args, **kwargs): + snapshot = self.get_object() + if snapshot.details.get('status') == 'success': + preview_url = "{}{}?form={}".format( + settings.ENKETO_SERVER, + settings.ENKETO_PREVIEW_URI, + reverse(viewname='assetsnapshot-detail', + format='xml', + kwargs={'uid': snapshot.uid}, + request=request, + ), + ) + return HttpResponseRedirect(preview_url) + else: + response_data = copy.copy(snapshot.details) + return Response(response_data, template_name='preview_error.html') + + +class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): + model = AssetFile + lookup_field = 'uid' + filter_backends = (RelatedAssetPermissionsFilter,) + serializer_class = AssetFileSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + return _queryset + + def perform_create(self, serializer): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + serializer.save( + asset=asset, + user=self.request.user + ) + + def perform_destroy(self, *args, **kwargs): + asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) + if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): + raise exceptions.PermissionDenied() + return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) + + class PrivateContentView(PrivateStorageDetailView): + model = AssetFile + model_file_field = 'content' + def can_access_file(self, private_file): + return private_file.request.user.has_perm( + PERM_VIEW_ASSET, private_file.parent_object.asset) + + @detail_route(methods=['get']) + def content(self, *args, **kwargs): + view = self.PrivateContentView.as_view( + model=AssetFile, + slug_url_kwarg='uid', + slug_field='uid', + model_file_field='content' + ) + af = self.get_object() + # TODO: simply redirect if external storage with expiring tokens (e.g. + # Amazon S3) is used? + # return HttpResponseRedirect(af.content.url) + return view(self.request, uid=af.uid) + + +class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## + This endpoint is only used to trigger asset's hooks if any. + + Tells the hooks to post an instance to external servers. +
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) + + +class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): + """ + ## List of submissions for a specific asset + +
+    GET /assets/{asset_uid}/submissions/
+    
+ + By default, JSON format is used but XML format can be used too. +
+    GET /assets/{asset_uid}/submissions.xml
+    GET /assets/{asset_uid}/submissions.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/?format=xml
+    GET /assets/{asset_uid}/submissions/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + * `id` - is the unique identifier of a specific submission + + **It's not allowed to create submissions with `kpi`'s API** + + Retrieves current submission +
+    GET /assets/{uid}/submissions/{id}/
+    
+ + It's also possible to specify the format. + +
+    GET /assets/{uid}/submissions/{id}.xml
+    GET /assets/{uid}/submissions/{id}.json
+    
+ + or + +
+    GET /assets/{asset_uid}/submissions/{id}/?format=xml
+    GET /assets/{asset_uid}/submissions/{id}/?format=json
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + Deletes current submission +
+    DELETE /assets/{uid}/submissions/{id}/
+    
+ + + > Example + > + > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ + + + Update current submission + + _It's not possible to update a submission directly with `kpi`'s API. + Instead, it returns the link where the instance can be opened for edition._ + +
+    GET /assets/{uid}/submissions/{id}/edit/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ + + + ### Validation statuses + + Retrieves the validation status of a submission. +
+    GET /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + Update the validation of a submission +
+    PATCH /assets/{uid}/submissions/{id}/validation_status/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ + + > **Payload** + > + > { + > "validation_status.uid": + > } + + where `` is a string and can be one of theses values: + + - `validation_status_approved` + - `validation_status_not_approved` + - `validation_status_on_hold` + + Bulk update +
+    PATCH /assets/{uid}/submissions/validation_statuses/
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ + + > **Payload** + > + > { + > "submissions_ids": [{integer}], + > "validation_status.uid": + > } + + + ### CURRENT ENDPOINT + """ + parent_model = Asset + renderer_classes = (renderers.BrowsableAPIRenderer, + renderers.JSONRenderer, + SubmissionXMLRenderer + ) + permission_classes = (SubmissionPermission,) + + def _get_asset(self): + + if not hasattr(self, "_asset"): + asset_uid = self.get_parents_query_dict()['asset'] + asset = get_object_or_404(self.parent_model, uid=asset_uid) + self._asset = asset + + return self._asset + + def _get_deployment(self): + """ + Returns the deployment for the asset specified by the request + """ + asset = self._get_asset() + + if not asset.has_deployment: + raise serializers.ValidationError( + _('The specified asset has not been deployed')) + return asset.deployment + + def destroy(self, request, *args, **kwargs): + deployment = self._get_deployment() + pk = kwargs.get("pk") + json_response = deployment.delete_submission(pk, user=request.user) + return Response(**json_response) + + @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) + def edit(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) + return Response(**json_response) + + def list(self, request, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submissions = deployment.get_submissions(format_type=format_type, **filters) + return Response(list(submissions)) + + def retrieve(self, request, pk, *args, **kwargs): + format_type = kwargs.get("format", request.GET.get("format", "json")) + deployment = self._get_deployment() + filters = self._filter_mongo_query(request) + submission = deployment.get_submission(pk, format_type=format_type, **filters) + if not submission: + raise Http404 + return Response(submission) + + @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_status(self, request, pk, *args, **kwargs): + deployment = self._get_deployment() + if request.method == "PATCH": + json_response = deployment.set_validate_status(pk, request.data, request.user) + else: + json_response = deployment.get_validate_status(pk, request.GET, request.user) + + return Response(**json_response) + + @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) + def validation_statuses(self, request, *args, **kwargs): + deployment = self._get_deployment() + json_response = deployment.set_validate_statuses(request.data, request.user) + + return Response(**json_response) + + def _filter_mongo_query(self, request): + """ + Build filters to pass to Mongo query. + Acts like Django `filter_backends` + + :param request: + :return: dict + """ + filters = {} + asset = self._get_asset() + + if request.method == "GET": + filters = request.GET.dict() + + submitted_by = asset.get_usernames_for_restricted_perm(request.user) + + filters.update({ + "submitted_by": submitted_by + }) + return filters + + +class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + model = AssetVersion + lookup_field = 'uid' + filter_backends = ( + AssetOwnerFilterBackend, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetVersionListSerializer + else: + return AssetVersionSerializer + + def get_queryset(self): + _asset_uid = self.get_parents_query_dict()['asset'] + _deployed = self.request.query_params.get('deployed', None) + _queryset = self.model.objects.filter(asset__uid=_asset_uid) + if _deployed is not None: + _queryset = _queryset.filter(deployed=_deployed) + if self.action == 'list': + # Save time by only retrieving fields from the DB that the + # serializer will use + _queryset = _queryset.only( + 'uid', 'deployed', 'date_modified', 'asset_id') + # `AssetVersionListSerializer.get_url()` asks for the asset UID + _queryset = _queryset.select_related('asset__uid') + return _queryset + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + * Assign a asset to a collection partially implemented + * Run a partial update of a asset TODO + + TODO Complete documentation + + ## List of asset endpoints + + Lists the asset endpoints accessible to requesting user, for anonymous access + a list of public data endpoints is returned. + +
+    GET /assets/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/ + + Get an hash of all `version_id`s of assets. + Useful to detect any changes in assets with only one call to `API` + +
+    GET /assets/hash/
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/hash/ + + ## CRUD + + * `uid` - is the unique identifier of a specific asset + + Retrieves current asset +
+    GET /assets/{uid}/
+    
+ + + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ + + Creates or clones an asset. +
+    POST /assets/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/ + + + > **Payload to create a new asset** + > + > { + > "name": {string}, + > "settings": { + > "description": {string}, + > "sector": {string}, + > "country": {string}, + > "share-metadata": {boolean} + > }, + > "asset_type": {string} + > } + + > **Payload to clone an asset** + > + > { + > "clone_from": {string}, + > "name": {string}, + > "asset_type": {string} + > } + + where `asset_type` must be one of these values: + + * block (can be cloned to `block`, `question`, `survey`, `template`) + * question (can be cloned to `question`, `survey`, `template`) + * survey (can be cloned to `block`, `question`, `survey`, `template`) + * template (can be cloned to `survey`, `template`) + + Settings are cloned only when type of assets are `survey` or `template`. + In that case, `share-metadata` is not preserved. + + When creating a new `block` or `question` asset, settings are not saved either. + + ### Deployment + + Retrieves the existing deployment, if any. +
+    GET /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Creates a new deployment, but only if a deployment does not exist already. +
+    POST /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Updates the `active` field of the existing deployment. +
+    PATCH /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier +
+    PUT /assets/{uid}/deployment
+    
+ + > Example + > + > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment + + + ### Permissions + Updates permissions of the specific asset +
+    PATCH /assets/{uid}/permissions
+    
+ + > Example + > + > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions + + ### CURRENT ENDPOINT + """ + + # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() + queryset = Asset.objects.all() + + serializer_class = AssetSerializer + lookup_field = 'uid' + permission_classes = (IsOwnerOrReadOnly,) + filter_backends = (KpiObjectPermissionsFilter, SearchFilter) + + renderer_classes = (renderers.BrowsableAPIRenderer, + AssetJsonRenderer, + SSJsonRenderer, + XFormRenderer, + XlsRenderer, + ) + + def get_serializer_class(self): + if self.action == 'list': + return AssetListSerializer + else: + return AssetSerializer + + def get_queryset(self, *args, **kwargs): + queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) + if self.action == 'list': + return queryset.model.optimize_queryset_for_list(queryset) + else: + # This is called to retrieve an individual record. How much do we + # have to care about optimizations for that? + return queryset + + def _get_clone_serializer(self, current_asset=None): + """ + Gets the serializer from cloned object + :param current_asset: Asset. Asset to be updated. + :return: AssetSerializer + """ + original_uid = self.request.data[CLONE_ARG_NAME] + original_asset = get_object_or_404(Asset, uid=original_uid) + if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: + original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) + source_version = get_object_or_404( + original_asset.asset_versions, uid=original_version_id) + else: + source_version = original_asset.asset_versions.first() + + view_perm = get_perm_name('view', original_asset) + if not self.request.user.has_perm(view_perm, original_asset): + raise Http404 + + partial_update = isinstance(current_asset, Asset) + cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) + if partial_update: + return self.get_serializer(current_asset, data=cloned_data, partial=True) + else: + return self.get_serializer(data=cloned_data) + + def _prepare_cloned_data(self, original_asset, source_version, partial_update): + """ + Some business rules must be applied when cloning an asset to another with a different type. + It prepares the data to be cloned accordingly. + + It raises an exception if source and destination are not compatible for cloning. + + :param original_asset: Asset + :param source_version: AssetVersion + :param partial_update: Boolean + :return: dict + """ + if self._validate_destination_type(original_asset): + # `to_clone_dict()` returns only `name`, `content`, `asset_type`, + # and `tag_string` + cloned_data = original_asset.to_clone_dict(version=source_version) + + # Allow the user's request data to override `cloned_data` + cloned_data.update(self.request.data.items()) + + if partial_update: + # Because we're updating an asset from another which can have another type, + # we need to remove `asset_type` from clone data to ensure it's not updated + # when serializer is initialized. + cloned_data.pop("asset_type", None) + else: + # Change asset_type if needed. + cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) + + cloned_asset_type = cloned_data.get("asset_type") + # Settings are: Country, Description, Sector and Share-metadata + # Copy settings only when original_asset is `survey` or `template` + # and `asset_type` property of `cloned_data` is `survey` or `template` + # or None (partial_update) + if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + + settings = original_asset.settings.copy() + settings.pop("share-metadata", None) + + cloned_data_settings = cloned_data.get("settings", {}) + + # Depending of the client payload. settings can be JSON or string. + # if it's a string. Let's load it to be able to merge it. + if not isinstance(cloned_data_settings, dict): + cloned_data_settings = json.loads(cloned_data_settings) + + settings.update(cloned_data_settings) + cloned_data['settings'] = json.dumps(settings) + + # until we get content passed as a dict, transform the content obj to a str + # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. + cloned_data["content"] = json.dumps(cloned_data.get("content")) + return cloned_data + else: + raise BadAssetTypeException("Destination type is not compatible with source type") + + def _validate_destination_type(self, original_asset_): + """ + Validates if destination asset can be cloned from source asset. + :param original_asset_ Asset: Source + :return: Boolean + """ + is_valid = True + + if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: + destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) + if destination_type in dict(ASSET_TYPES).values(): + source_type = original_asset_.asset_type + is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) + else: + is_valid = False + + return is_valid + + def create(self, request, *args, **kwargs): + if CLONE_ARG_NAME in request.data: + serializer = self._get_clone_serializer() + else: + serializer = self.get_serializer(data=request.data) + + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + + @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) + def hash(self, request): + """ + Creates an hash of `version_id` of all accessible assets by the user. + Useful to detect changes between each request. + + :param request: + :return: JSON + """ + user = self.request.user + if user.is_anonymous(): + raise exceptions.NotAuthenticated() + else: + accessible_assets = get_objects_for_user( + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + .order_by("uid") + + assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] + # Sort alphabetically + assets_version_ids.sort() + + if len(assets_version_ids) > 0: + hash = md5("".join(assets_version_ids)).hexdigest() + else: + hash = "" + + return Response({ + "hash": hash + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.content', + 'uid': asset.uid, + 'data': asset.to_ss_structure(), + }) + + @detail_route(renderer_classes=[renderers.JSONRenderer]) + def valid_content(self, request, uid): + asset = self.get_object() + return Response({ + 'kind': 'asset.valid_content', + 'uid': asset.uid, + 'data': to_xlsform_structure(asset.content), + }) + + @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) + def koboform(self, request, *args, **kwargs): + asset = self.get_object() + return Response({'asset': asset, }, template_name='koboform.html') + + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) + def table_view(self, request, *args, **kwargs): + sa = self.get_object() + md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) + return Response('\n' + '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        '''
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        '''
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                        )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def permissions(self, request, uid):
+        target_asset = self.get_object()
+        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        user = request.user
+        response = {}
+        http_status = status.HTTP_204_NO_CONTENT
+
+        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
+            user.has_perm(PERM_VIEW_ASSET, source_asset):
+            if not target_asset.copy_permissions_from(source_asset):
+                http_status = status.HTTP_400_BAD_REQUEST
+                response = {"detail": "Source and destination objects don't seem to have the same type"}
+        else:
+            raise exceptions.PermissionDenied()
+
+        return Response(response, status=http_status)
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        ''' Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        '''
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+
+
+def _wrap_html_pre(content):
+    return "
%s
" % content + + +class SitewideMessageViewSet(viewsets.ModelViewSet): + queryset = SitewideMessage.objects.all() + serializer_class = SitewideMessageSerializer + + +class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): + queryset = UserCollectionSubscription.objects.none() + serializer_class = UserCollectionSubscriptionSerializer + lookup_field = 'uid' + + def get_queryset(self): + user = self.request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + criteria = {'user': user} + if 'collection__uid' in self.request.query_params: + criteria['collection__uid'] = self.request.query_params[ + 'collection__uid'] + return UserCollectionSubscription.objects.filter(**criteria) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class TokenView(APIView): + def _which_user(self, request): + ''' + Determine the user from `request`, allowing superusers to specify + another user by passing the `username` query parameter + ''' + if request.user.is_anonymous(): + raise exceptions.NotAuthenticated() + + if 'username' in request.query_params: + # Allow superusers to get others' tokens + if request.user.is_superuser: + user = get_object_or_404( + User, + username=request.query_params['username'] + ) + else: + raise exceptions.PermissionDenied() + else: + user = request.user + return user + + def get(self, request, *args, **kwargs): + ''' Retrieve an existing token only ''' + user = self._which_user(request) + token = get_object_or_404(Token, user=user) + return Response({'token': token.key}) + + def post(self, request, *args, **kwargs): + ''' Return a token, creating a new one if none exists ''' + user = self._which_user(request) + token, created = Token.objects.get_or_create(user=user) + return Response( + {'token': token.key}, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + + def delete(self, request, *args, **kwargs): + ''' Delete an existing token and do not generate a new one ''' + user = self._which_user(request) + with transaction.atomic(): + token = get_object_or_404(Token, user=user) + token.delete() + return Response({}, status=status.HTTP_204_NO_CONTENT) + + +class EnvironmentView(APIView): + ''' GET-only view for certain server-provided configuration data ''' + + CONFIGS_TO_EXPOSE = [ + 'TERMS_OF_SERVICE_URL', + 'PRIVACY_POLICY_URL', + 'SOURCE_CODE_URL', + 'SUPPORT_URL', + 'SUPPORT_EMAIL', + ] + + def get(self, request, *args, **kwargs): + ''' + Return the lowercased key and value of each setting in + `CONFIGS_TO_EXPOSE` + ''' + return Response({ + key.lower(): getattr(constance.config, key) + for key in self.CONFIGS_TO_EXPOSE + }) From 45ee791cbed435f7bcbc424fd194962fcf66309e Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 07:47:22 -0400 Subject: [PATCH 025/499] Removed useless imports and unrelated codes from views files --- kpi/views/__init__.py | 1637 +------------------- kpi/views/asset.py | 1153 +-------------- kpi/views/asset_file.py | 1593 +------------------- kpi/views/asset_snapshot.py | 1578 +------------------- kpi/views/asset_version.py | 1609 +------------------- kpi/views/authorized_application_user.py | 1624 +------------------- kpi/views/collection.py | 1555 +------------------ kpi/views/current_user.py | 17 +- kpi/views/environment.py | 1638 --------------------- kpi/views/export_task.py | 1556 +------------------ kpi/views/hook_signal.py | 1564 +------------------- kpi/views/import_task.py | 1581 +------------------- kpi/views/no_update_model.py | 1638 +-------------------- kpi/views/object_permission.py | 1583 +------------------- kpi/views/one_time_authentication_key.py | 1623 +------------------- kpi/views/sitewide_message.py | 1630 +------------------- kpi/views/submission.py | 1411 +----------------- kpi/views/tag.py | 1597 +------------------- kpi/views/token.py | 1618 -------------------- kpi/views/user.py | 1619 +------------------- kpi/views/user_collection_subscription.py | 1616 +------------------- 21 files changed, 128 insertions(+), 31312 deletions(-) diff --git a/kpi/views/__init__.py b/kpi/views/__init__.py index d149231057..e2cff25cad 100644 --- a/kpi/views/__init__.py +++ b/kpi/views/__init__.py @@ -1,18 +1,6 @@ -<<<<<<< HEAD # coding: utf-8 -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -import base64 -import copy ->>>>>>> WIP - splitted views.py in several files import datetime -import json -from hashlib import md5 -from itertools import chain -<<<<<<< HEAD from django.contrib.auth.decorators import login_required from django.template.response import TemplateResponse from django.conf import settings @@ -21,25 +9,10 @@ from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import resolve_url -======= -import constance - -from django.conf import settings -from django.contrib.auth import login, logout -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User -from django.db import transaction -from django.db.models import Q -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse ->>>>>>> WIP - splitted views.py in several files from django.utils.http import is_safe_url from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST -<<<<<<< HEAD from rest_framework import exceptions from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, authentication_classes @@ -48,121 +21,10 @@ from kpi.models import AuthorizedApplication, OneTimeAuthenticationKey from kpi.models.authorized_application import ApplicationTokenAuthentication from kpi.serializers import AuthorizedApplicationUserSerializer -from ona.authentication import JWTAuthentication -======= -from private_storage.views import PrivateStorageDetailView -from rest_framework import exceptions, mixins, renderers, status, viewsets -from rest_framework.authtoken.models import Token -from rest_framework.decorators import ( - api_view, - authentication_classes, - detail_route, - list_route -) -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin -from taggit.models import Tag - -from hub.models import SitewideMessage -from kobo.apps.hook.utils import HookUtils -from kobo.static_lists import COUNTRIES, LANGUAGES, SECTORS -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging -from .constants import ( - ASSET_TYPES, - ASSET_TYPE_ARG_NAME, - ASSET_TYPE_SURVEY, - ASSET_TYPE_TEMPLATE, - CLONE_ARG_NAME, - CLONE_COMPATIBLE_TYPES, - CLONE_FROM_VERSION_ID_ARG_NAME, - COLLECTION_CLONE_FIELDS, -) -from .deployment_backends.backends import DEPLOYMENT_BACKENDS -from .filters import ( - AssetOwnerFilterBackend, - KpiAssignedObjectPermissionsFilter, - KpiObjectPermissionsFilter, - RelatedAssetPermissionsFilter, - SearchFilter -) -from .highlighters import highlight_xform -from .model_utils import disable_auto_field_update, remove_string_prefix -from .models import ( - Asset, - AssetFile, - AssetSnapshot, - AssetVersion, - AuthorizedApplication, - Collection, - ExportTask, - ImportTask, - ObjectPermission, - OneTimeAuthenticationKey, - UserCollectionSubscription -) -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer, -) -from .serializers import ( - AssetFileSerializer, - AssetListSerializer, - AssetSerializer, - AssetSnapshotSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AuthorizedApplicationUserSerializer, - CollectionListSerializer, - CollectionSerializer, - CreateUserSerializer, - CurrentUserSerializer, - DeploymentSerializer, - ExportTaskSerializer, - ImportTaskListSerializer, - ImportTaskSerializer, - ObjectPermissionSerializer, - OneTimeAuthenticationKeySerializer, - SitewideMessageSerializer, - TagListSerializer, - TagSerializer, - UserCollectionSubscriptionSerializer, - UserSerializer -) -from .tasks import import_in_background, export_in_background -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable - -from ona.authentication import ( - JWTAuthentication, encode_payload, decode_payload -) -from .model_utils import grant_default_model_level_perms ->>>>>>> WIP - splitted views.py in several files +@login_required def home(request): - cookie_jwt = request.COOKIES.get(settings.KPI_COOKIE_NAME) - if request.user.is_anonymous and cookie_jwt: - auth_class = JWTAuthentication() - user, token = auth_class.authenticate(request) - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) return TemplateResponse(request, "index.html") @@ -170,7 +32,6 @@ def browser_tests(request): return TemplateResponse(request, "browser_tests.html") -<<<<<<< HEAD @api_view(['POST']) @authentication_classes([ApplicationTokenAuthentication]) def authorized_application_authenticate_user(request): @@ -178,270 +39,6 @@ def authorized_application_authenticate_user(request): Returns a user-level API token when given a valid username and password. The request header must include an authorized application key """ -======= -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous: - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - @detail_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer]) - def grant_default_model_level_perms(self, request, *args, **kwargs): - user = self.get_object() - grant_default_model_level_perms(user) - - return Response( - data={ - "detail": ("Successfully granted default model level " - "perms to user %s." % user.username) - }, - status=status.HTTP_201_CREATED - ) - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' ->>>>>>> WIP - splitted views.py in several files if type(request.auth) is not AuthorizedApplication: # Only specially-authorized applications are allowed to authenticate # users this way @@ -474,7 +71,6 @@ def authorized_application_authenticate_user(request): return Response(response_data) -<<<<<<< HEAD @require_POST @csrf_exempt def one_time_login(request): @@ -483,31 +79,6 @@ def one_time_login(request): object, log in the User specified in that object and redirect to the location specified in the 'next' parameter """ -======= -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' ->>>>>>> WIP - splitted views.py in several files try: key = request.POST['key'] except KeyError: @@ -538,1210 +109,6 @@ def one_time_login(request): return HttpResponseRedirect(next_) -<<<<<<< HEAD # TODO Verify if it's still used def _wrap_html_pre(content): - return "
%s
" % content -======= -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous: - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous: - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous: - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous: - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous: - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - - instance_id = request.data.get("instance_id") - if instance_id is None: - raise exceptions.ValidationError( - {'instance_id': _('This field is required.')}) - - instance = None - try: - instance = asset.deployment.get_submission(instance_id) - except ValueError: - raise Http404 - - # Check if instance really belongs to Asset. - if not (instance and - instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - raise Http404 - - if HookUtils.call_services(asset, instance_id): - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - else: - # call_services() refused to launch any task because this - # instance already has a `HookLog` - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for editing._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get('format', request.GET.get('format', 'json')) - deployment = self._get_deployment() - filters = request.GET.dict() - # remove `format` from filters, it's redundant. - filters.pop('format', None) - # Do not allow requests to retrieve more than `SUBMISSION_LIST_LIMIT` - # submissions at one time - limit = filters.get('limit', settings.SUBMISSION_LIST_LIMIT) - try: - limit = int(limit) - except ValueError: - raise exceptions.ValidationError( - {'limit': _('A valid integer is required')} - ) - filters['limit'] = min(limit, settings.SUBMISSION_LIST_LIMIT) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get('format', request.GET.get('format', 'json')) - deployment = self._get_deployment() - filters = request.GET.dict() - # remove `format` from filters, it's redundant. - filters.pop('format', None) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validation_status(pk, request.data, request.user) - else: - json_response = deployment.get_validation_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validation_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backend` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - if asset.has_perm(request.user, "supervisor_view_submissions"): - pass - - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous: - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous:
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous: - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous: - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - """ - GET-only view for certain server-provided configuration data - """ - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - """ - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE`, along with the static lists of sectors, countries, - all known languages, and languages for which the interface has - translations. - """ - data = { - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - } - data['available_sectors'] = SECTORS - data['available_countries'] = COUNTRIES - data['all_languages'] = LANGUAGES - data['interface_languages'] = settings.LANGUAGES - return Response(data) ->>>>>>> WIP - splitted views.py in several files + return "
%s
" % content \ No newline at end of file diff --git a/kpi/views/asset.py b/kpi/views/asset.py index 1b9a7c435b..cf6b8eaab9 100644 --- a/kpi/views/asset.py +++ b/kpi/views/asset.py @@ -1,1031 +1,33 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain import copy -from hashlib import md5 import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes +from hashlib import md5 + +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import exceptions, renderers, status, viewsets from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView from rest_framework_extensions.mixins import NestedViewSetMixin -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils +from kpi.constants import ASSET_TYPES, ASSET_TYPE_ARG_NAME, ASSET_TYPE_SURVEY, \ + ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + CLONE_FROM_VERSION_ID_ARG_NAME, PERM_SHARE_ASSET, PERM_VIEW_ASSET +from kpi.deployment_backends.backends import DEPLOYMENT_BACKENDS from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset +from kpi.filters import KpiObjectPermissionsFilter, SearchFilter +from kpi.highlighters import highlight_xform +from kpi.models import Asset +from kpi.models.object_permission import get_anonymous_user, get_objects_for_user +from kpi.permissions import IsOwnerOrReadOnly, PostMappedToChangePermission, \ + get_perm_name +from kpi.renderers import AssetJsonRenderer, SSJsonRenderer, XFormRenderer, \ + XlsRenderer +from kpi.serializers import AssetListSerializer, AssetSerializer, DeploymentSerializer +from kpi.utils.kobo_to_xlsform import to_xlsform_structure +from kpi.utils.ss_structure_to_mdtable import ss_structure_to_mdtable class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): @@ -1260,7 +262,7 @@ def _prepare_cloned_data(self, original_asset, source_version, partial_update): # and `asset_type` property of `cloned_data` is `survey` or `template` # or None (partial_update) if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: + original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: settings = original_asset.settings.copy() settings.pop("share-metadata", None) @@ -1326,7 +328,7 @@ def hash(self, request): raise exceptions.NotAuthenticated() else: accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ + user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY) \ .order_by("uid") assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] @@ -1395,14 +397,14 @@ def xform(self, request, *args, **kwargs): permission_classes=[PostMappedToChangePermission] ) def deployment(self, request, uid): - ''' + """ A GET request retrieves the existing deployment, if any. A POST request creates a new deployment, but only if a deployment does not exist already. A PATCH request updates the `active` field of the existing deployment. A PUT request overwrites the entire deployment, including the form contents, but does not change the deployment's identifier - ''' + """ asset = self.get_object() serializer_context = self.get_serializer_context() serializer_context['asset'] = asset @@ -1439,7 +441,7 @@ def deployment(self, request, uid): raise exceptions.MethodNotAllowed( method=request.method, detail='Use PATCH to update an existing deployment' - ) + ) serializer = DeploymentSerializer( data=request.data, context=serializer_context @@ -1481,7 +483,7 @@ def permissions(self, request, uid): http_status = status.HTTP_204_NO_CONTENT if user.has_perm(PERM_SHARE_ASSET, target_asset) and \ - user.has_perm(PERM_VIEW_ASSET, source_asset): + user.has_perm(PERM_VIEW_ASSET, source_asset): if not target_asset.copy_permissions_from(source_asset): http_status = status.HTTP_400_BAD_REQUEST response = {"detail": "Source and destination objects don't seem to have the same type"} @@ -1517,9 +519,9 @@ def perform_destroy(self, instance): return super(AssetViewSet, self).perform_destroy(instance) def finalize_response(self, request, response, *args, **kwargs): - ''' Manipulate the headers as appropriate for the requested format. + """ Manipulate the headers as appropriate for the requested format. See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658. - ''' + """ # If the request fails at an early stage, e.g. the user has no # model-level permissions, accepted_renderer won't be present. if hasattr(request, 'accepted_renderer'): @@ -1537,102 +539,3 @@ def finalize_response(self, request, response, *args, **kwargs): return super(AssetViewSet, self).finalize_response( request, response, *args, **kwargs) - - -def _wrap_html_pre(content): - return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/asset_file.py b/kpi/views/asset_file.py index 1b9a7c435b..8bbdf6cf41 100644 --- a/kpi/views/asset_file.py +++ b/kpi/views/asset_file.py @@ -1,661 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag from private_storage.views import PrivateStorageDetailView +from rest_framework import exceptions +from rest_framework.decorators import detail_route +from rest_framework_extensions.mixins import NestedViewSetMixin -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') +from kpi.constants import PERM_CHANGE_ASSET, PERM_VIEW_ASSET +from kpi.filters import RelatedAssetPermissionsFilter +from kpi.models import Asset, AssetFile +from kpi.serializers import AssetFileSerializer - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') +from .no_update_model import NoUpdateModelViewSet class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): @@ -687,6 +43,7 @@ def perform_destroy(self, *args, **kwargs): class PrivateContentView(PrivateStorageDetailView): model = AssetFile model_file_field = 'content' + def can_access_file(self, private_file): return private_file.request.user.has_perm( PERM_VIEW_ASSET, private_file.parent_object.asset) @@ -704,935 +61,3 @@ def content(self, *args, **kwargs): # Amazon S3) is used? # return HttpResponseRedirect(af.content.url) return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/asset_snapshot.py b/kpi/views/asset_snapshot.py index 1b9a7c435b..0ec7dea6a9 100644 --- a/kpi/views/asset_snapshot.py +++ b/kpi/views/asset_snapshot.py @@ -1,600 +1,22 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain import copy -from hashlib import md5 -import json -import base64 -import datetime -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse +from django.http import HttpResponseRedirect from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes +from rest_framework import renderers from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser from rest_framework.response import Response from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView +from kpi.filters import RelatedAssetPermissionsFilter +from kpi.highlighters import highlight_xform +from kpi.models import AssetSnapshot +from kpi.renderers import XMLRenderer +from kpi.serializers import AssetSnapshotSerializer -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) +from .no_update_model import NoUpdateModelViewSet class AssetSnapshotViewSet(NoUpdateModelViewSet): @@ -624,10 +46,10 @@ def filter_queryset(self, queryset): @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) def xform(self, request, *args, **kwargs): - ''' + """ This route will render the XForm into syntax-highlighted HTML. It is useful for debugging pyxform transformations - ''' + """ snapshot = self.get_object() response_data = copy.copy(snapshot.details) options = { @@ -656,983 +78,3 @@ def preview(self, request, *args, **kwargs): else: response_data = copy.copy(snapshot.details) return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/asset_version.py b/kpi/views/asset_version.py index 1b9a7c435b..223c713b8d 100644 --- a/kpi/views/asset_version.py +++ b/kpi/views/asset_version.py @@ -1,1002 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView +from rest_framework import viewsets from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters +from kpi.filters import AssetOwnerFilterBackend +from kpi.models import AssetVersion +from kpi.serializers import AssetVersionListSerializer, AssetVersionSerializer class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): @@ -1026,613 +35,3 @@ def get_queryset(self): # `AssetVersionListSerializer.get_url()` asks for the asset UID _queryset = _queryset.select_related('asset__uid') return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/authorized_application_user.py b/kpi/views/authorized_application_user.py index 1b9a7c435b..1d5bb397dd 100644 --- a/kpi/views/authorized_application_user.py +++ b/kpi/views/authorized_application_user.py @@ -1,356 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user +from rest_framework import viewsets, mixins, exceptions +from kpi.models import AuthorizedApplication +from kpi.models.authorized_application import ApplicationTokenAuthentication +from kpi.serializers import CreateUserSerializer class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, @@ -359,6 +14,7 @@ class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, queryset = User.objects.all() serializer_class = CreateUserSerializer lookup_field = 'username' + def create(self, request, *args, **kwargs): if type(request.auth) is not AuthorizedApplication: # Only specially-authorized applications are allowed to create @@ -366,1273 +22,3 @@ def create(self, request, *args, **kwargs): raise exceptions.PermissionDenied() return super(AuthorizedApplicationUserViewSet, self).create( request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/collection.py b/kpi/views/collection.py index 1b9a7c435b..cf1e096978 100644 --- a/kpi/views/collection.py +++ b/kpi/views/collection.py @@ -1,197 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import viewsets, status, exceptions from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) +from kpi.filters import KpiObjectPermissionsFilter, SearchFilter +from kpi.models import Collection +from kpi.model_utils import disable_auto_field_update +from kpi.permissions import IsOwnerOrReadOnly, get_perm_name +from kpi.serializers import CollectionSerializer, CollectionListSerializer +from kpi.constants import CLONE_ARG_NAME, COLLECTION_CLONE_FIELDS class CollectionViewSet(viewsets.ModelViewSet): @@ -248,7 +68,7 @@ def perform_create(self, serializer): serializer.save(owner=self.request.user) def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' + """ Only the owner is allowed to change `discoverable_when_public` """ original_collection = self.get_object() if (self.request.user != original_collection.owner and 'discoverable_when_public' in serializer.validated_data and @@ -281,1358 +101,3 @@ def get_serializer_class(self): return CollectionListSerializer else: return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/current_user.py b/kpi/views/current_user.py index df0c608045..2f48578ba4 100644 --- a/kpi/views/current_user.py +++ b/kpi/views/current_user.py @@ -8,16 +8,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login from django.contrib.auth.models import User +<<<<<<< HEAD from django.contrib.auth.decorators import login_required from django.db import transaction from django.db.models import Q, Count @@ -351,6 +343,10 @@ def __init__(self, *args, **kwargs): def list(self, request, *args, **kwargs): raise exceptions.PermissionDenied() >>>>>>> WIP - splitted views.py in several files +======= +from rest_framework import viewsets +from kpi.serializers import CurrentUserSerializer +>>>>>>> Removed useless imports and unrelated codes from views files class CurrentUserViewSet(viewsets.ModelViewSet): @@ -360,6 +356,7 @@ class CurrentUserViewSet(viewsets.ModelViewSet): def get_object(self): return self.request.user <<<<<<< HEAD +<<<<<<< HEAD ======= @@ -1647,3 +1644,5 @@ def get(self, request, *args, **kwargs): for key in self.CONFIGS_TO_EXPOSE }) >>>>>>> WIP - splitted views.py in several files +======= +>>>>>>> Removed useless imports and unrelated codes from views files diff --git a/kpi/views/environment.py b/kpi/views/environment.py index c8fe46232c..a63d9356ae 100644 --- a/kpi/views/environment.py +++ b/kpi/views/environment.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD # coding: utf-8 import constance from django.conf import settings @@ -14,1636 +13,12 @@ class EnvironmentView(APIView): GET-only view for certain server-provided configuration data """ -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - ->>>>>>> WIP - splitted views.py in several files CONFIGS_TO_EXPOSE = [ 'TERMS_OF_SERVICE_URL', 'PRIVACY_POLICY_URL', 'SOURCE_CODE_URL', 'SUPPORT_URL', 'SUPPORT_EMAIL', -<<<<<<< HEAD 'COMMUNITY_URL', ] @@ -1664,16 +39,3 @@ def get(self, request, *args, **kwargs): data['interface_languages'] = settings.LANGUAGES data['submission_placeholder'] = SUBMISSION_PLACEHOLDER return Response(data) -======= - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) ->>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views/export_task.py b/kpi/views/export_task.py index 1b9a7c435b..d6f2c17ea5 100644 --- a/kpi/views/export_task.py +++ b/kpi/views/export_task.py @@ -1,517 +1,16 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser +from rest_framework import status, exceptions from rest_framework.response import Response from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) +from kpi.models import ExportTask +from kpi.models.import_export_task import _resolve_url_to_asset_or_collection +from kpi.model_utils import remove_string_prefix +from kpi.serializers import ExportTaskSerializer +from kpi.tasks import export_in_background +from .no_update_model import NoUpdateModelViewSet class ExportTaskViewSet(NoUpdateModelViewSet): @@ -595,1044 +94,3 @@ def create(self, request, *args, **kwargs): request=request), 'status': ExportTask.PROCESSING }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/hook_signal.py b/kpi/views/hook_signal.py index 1b9a7c435b..a4a8691a90 100644 --- a/kpi/views/hook_signal.py +++ b/kpi/views/hook_signal.py @@ -1,711 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt +from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser +from rest_framework import status, viewsets from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView from rest_framework_extensions.mixins import NestedViewSetMixin -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException +from kpi.models import Asset from kpi.utils.log import logging -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): """ ## @@ -772,867 +78,3 @@ def create(self, request, *args, **kwargs): response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/import_task.py b/kpi/views/import_task.py index 1b9a7c435b..56b10d5317 100644 --- a/kpi/views/import_task.py +++ b/kpi/views/import_task.py @@ -1,465 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json import base64 -import datetime -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser +from rest_framework import exceptions, status, viewsets from rest_framework.response import Response from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass +from kpi.models import ImportTask +from kpi.serializers import ImportTaskListSerializer, ImportTaskSerializer +from kpi.tasks import import_in_background class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): @@ -513,1126 +63,3 @@ def create(self, request, *args, **kwargs): 'status': ImportTask.PROCESSING }, status.HTTP_201_CREATED) - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/no_update_model.py b/kpi/views/no_update_model.py index dc0a606390..60c678937b 100644 --- a/kpi/views/no_update_model.py +++ b/kpi/views/no_update_model.py @@ -1,133 +1,7 @@ -<<<<<<< HEAD + # coding: utf-8 from rest_framework import viewsets from rest_framework import mixins -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") ->>>>>>> WIP - splitted views.py in several files class NoUpdateModelViewSet( @@ -137,1516 +11,8 @@ class NoUpdateModelViewSet( mixins.ListModelMixin, viewsets.GenericViewSet ): -<<<<<<< HEAD - """ - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. """ - pass -======= - ''' Inherit from everything that ModelViewSet does, except for UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) ->>>>>>> WIP - splitted views.py in several files + pass \ No newline at end of file diff --git a/kpi/views/object_permission.py b/kpi/views/object_permission.py index 1b9a7c435b..9c1d84da88 100644 --- a/kpi/views/object_permission.py +++ b/kpi/views/object_permission.py @@ -1,141 +1,14 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - +from rest_framework import exceptions -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass +from kpi.constants import PERM_SHARE_SUBMISSIONS +from kpi.filters import KpiAssignedObjectPermissionsFilter +from kpi.models import ObjectPermission +from kpi.serializers import ObjectPermissionSerializer +from .no_update_model import NoUpdateModelViewSet class ObjectPermissionViewSet(NoUpdateModelViewSet): @@ -192,1447 +65,3 @@ def perform_destroy(self, instance): instance.user, instance.permission.codename ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/one_time_authentication_key.py b/kpi/views/one_time_authentication_key.py index 1b9a7c435b..fda2304504 100644 --- a/kpi/views/one_time_authentication_key.py +++ b/kpi/views/one_time_authentication_key.py @@ -1,408 +1,13 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime +from rest_framework import exceptions +from rest_framework import mixins +from rest_framework import viewsets -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) +from kpi.models import AuthorizedApplication, OneTimeAuthenticationKey +from kpi.models.authorized_application import ApplicationTokenAuthentication +from kpi.serializers import OneTimeAuthenticationKeySerializer class OneTimeAuthenticationKeyViewSet( @@ -420,1219 +25,3 @@ def create(self, request, *args, **kwargs): raise exceptions.PermissionDenied() return super(OneTimeAuthenticationKeyViewSet, self).create( request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/sitewide_message.py b/kpi/views/sitewide_message.py index 1b9a7c435b..d9e177dd26 100644 --- a/kpi/views/sitewide_message.py +++ b/kpi/views/sitewide_message.py @@ -1,1638 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime +from rest_framework import viewsets -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content +from kpi.serializers import SitewideMessageSerializer class SitewideMessageViewSet(viewsets.ModelViewSet): queryset = SitewideMessage.objects.all() serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/submission.py b/kpi/views/submission.py index 1b9a7c435b..dbbf4bd02b 100644 --- a/kpi/views/submission.py +++ b/kpi/views/submission.py @@ -1,777 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt +from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes +from rest_framework import renderers, viewsets from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView from rest_framework_extensions.mixins import NestedViewSetMixin -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) +from kpi.models import Asset +from kpi.permissions import SubmissionPermission +from kpi.renderers import SubmissionXMLRenderer class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): @@ -997,642 +237,3 @@ def _filter_mongo_query(self, request): "submitted_by": submitted_by }) return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/tag.py b/kpi/views/tag.py index 1b9a7c435b..9c817f9cdf 100644 --- a/kpi/views/tag.py +++ b/kpi/views/tag.py @@ -1,286 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance +from django.db.models import Q +from rest_framework import viewsets from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION +from kpi.filters import SearchFilter +from kpi.models import Asset, Collection +from kpi.models.object_permission import get_anonymous_user, get_objects_for_user +from kpi.serializers import TagSerializer, TagListSerializer class TagViewSet(viewsets.ReadOnlyModelViewSet): @@ -298,10 +29,10 @@ def get_queryset(self, *args, **kwargs): user = get_anonymous_user() def _get_tags_on_items(content_type_name, avail_items): - ''' + """ return all ids of tags which are tagged to items of the given content_type - ''' + """ same_content_type = Q( taggit_taggeditem_items__content_type__model=content_type_name) same_id = Q( @@ -326,1313 +57,3 @@ def get_serializer_class(self): return TagListSerializer else: return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/token.py b/kpi/views/token.py index a36d3c82bb..7a78cfa964 100644 --- a/kpi/views/token.py +++ b/kpi/views/token.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD # coding: utf-8 from django.contrib.auth.models import User from django.db import transaction @@ -7,1595 +6,15 @@ from rest_framework.authtoken.models import Token from rest_framework.response import Response from rest_framework.views import APIView -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) ->>>>>>> WIP - splitted views.py in several files class TokenView(APIView): def _which_user(self, request): -<<<<<<< HEAD """ Determine the user from `request`, allowing superusers to specify another user by passing the `username` query parameter """ if request.user.is_anonymous: -======= - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): ->>>>>>> WIP - splitted views.py in several files raise exceptions.NotAuthenticated() if 'username' in request.query_params: @@ -1612,21 +31,13 @@ def _which_user(self, request): return user def get(self, request, *args, **kwargs): -<<<<<<< HEAD """ Retrieve an existing token only """ -======= - ''' Retrieve an existing token only ''' ->>>>>>> WIP - splitted views.py in several files user = self._which_user(request) token = get_object_or_404(Token, user=user) return Response({'token': token.key}) def post(self, request, *args, **kwargs): -<<<<<<< HEAD """ Return a token, creating a new one if none exists """ -======= - ''' Return a token, creating a new one if none exists ''' ->>>>>>> WIP - splitted views.py in several files user = self._which_user(request) token, created = Token.objects.get_or_create(user=user) return Response( @@ -1635,38 +46,9 @@ def post(self, request, *args, **kwargs): ) def delete(self, request, *args, **kwargs): -<<<<<<< HEAD """ Delete an existing token and do not generate a new one """ -======= - ''' Delete an existing token and do not generate a new one ''' ->>>>>>> WIP - splitted views.py in several files user = self._which_user(request) with transaction.atomic(): token = get_object_or_404(Token, user=user) token.delete() return Response({}, status=status.HTTP_204_NO_CONTENT) -<<<<<<< HEAD -======= - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) ->>>>>>> WIP - splitted views.py in several files diff --git a/kpi/views/user.py b/kpi/views/user.py index 1b9a7c435b..679d16ff61 100644 --- a/kpi/views/user.py +++ b/kpi/views/user.py @@ -1,331 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime - -from django.contrib.auth import login from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() +from rest_framework import exceptions, mixins, viewsets - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer +from kpi.models.authorized_application import ApplicationTokenAuthentication +from kpi.serializers import UserSerializer class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): @@ -343,1296 +23,3 @@ def __init__(self, *args, **kwargs): def list(self, request, *args, **kwargs): raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) diff --git a/kpi/views/user_collection_subscription.py b/kpi/views/user_collection_subscription.py index 1b9a7c435b..2d7052d23c 100644 --- a/kpi/views/user_collection_subscription.py +++ b/kpi/views/user_collection_subscription.py @@ -1,1551 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from distutils.util import strtobool -from itertools import chain -import copy -from hashlib import md5 -import json -import base64 -import datetime +from rest_framework import viewsets +from kpi.models import UserCollectionSubscription -from django.contrib.auth import login -from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.db import transaction -from django.db.models import Q, Count -from django.forms import model_to_dict -from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect -from django.utils.http import is_safe_url -from django.shortcuts import get_object_or_404, resolve_url -from django.template.response import TemplateResponse -from django.conf import settings -from django.views.decorators.http import require_POST -from django.views.decorators.csrf import csrf_exempt -from django.utils.translation import ugettext_lazy as _ -from rest_framework import ( - viewsets, - mixins, - renderers, - status, - exceptions, -) -from rest_framework.decorators import api_view -from rest_framework.decorators import renderer_classes -from rest_framework.decorators import detail_route, list_route -from rest_framework.decorators import authentication_classes -from rest_framework.parsers import MultiPartParser -from rest_framework.response import Response -from rest_framework.reverse import reverse -from rest_framework.authtoken.models import Token -from rest_framework.views import APIView -from rest_framework_extensions.mixins import NestedViewSetMixin - -import constance -from taggit.models import Tag -from private_storage.views import PrivateStorageDetailView - -from .filters import KpiAssignedObjectPermissionsFilter -from .filters import AssetOwnerFilterBackend -from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter -from .filters import SearchFilter -from .highlighters import highlight_xform -from hub.models import SitewideMessage -from .models import ( - Collection, - Asset, - AssetVersion, - AssetSnapshot, - AssetFile, - ImportTask, - ExportTask, - ObjectPermission, - AuthorizedApplication, - OneTimeAuthenticationKey, - UserCollectionSubscription, - ) -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.authorized_application import ApplicationTokenAuthentication -from .models.import_export_task import _resolve_url_to_asset_or_collection -from .model_utils import disable_auto_field_update, remove_string_prefix -from .permissions import ( - IsOwnerOrReadOnly, - PostMappedToChangePermission, - get_perm_name, - SubmissionPermission -) -from .renderers import ( - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XMLRenderer, - SubmissionXMLRenderer, - XlsRenderer,) -from .serializers import ( - AssetSerializer, AssetListSerializer, - AssetVersionListSerializer, - AssetVersionSerializer, - AssetFileSerializer, - AssetSnapshotSerializer, - SitewideMessageSerializer, - CollectionSerializer, CollectionListSerializer, - UserSerializer, - CurrentUserSerializer, CreateUserSerializer, - TagSerializer, TagListSerializer, - ImportTaskSerializer, ImportTaskListSerializer, - ExportTaskSerializer, - ObjectPermissionSerializer, - AuthorizedApplicationUserSerializer, - OneTimeAuthenticationKeySerializer, - DeploymentSerializer, - UserCollectionSubscriptionSerializer,) -from .utils.gravatar_url import gravatar_url -from .utils.kobo_to_xlsform import to_xlsform_structure -from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable -from .tasks import import_in_background, export_in_background -from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \ - COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \ - PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \ - PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS -from .deployment_backends.backends import DEPLOYMENT_BACKENDS - -from kobo.apps.hook.utils import HookUtils -from kpi.exceptions import BadAssetTypeException -from kpi.utils.log import logging - - -@login_required -def home(request): - return TemplateResponse(request, "index.html") - - -def browser_tests(request): - return TemplateResponse(request, "browser_tests.html") - - -class NoUpdateModelViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet -): - ''' - Inherit from everything that ModelViewSet does, except for - UpdateModelMixin. - ''' - pass - - -class ObjectPermissionViewSet(NoUpdateModelViewSet): - queryset = ObjectPermission.objects.all() - serializer_class = ObjectPermissionSerializer - lookup_field = 'uid' - filter_backends = (KpiAssignedObjectPermissionsFilter, ) - - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - - def perform_create(self, serializer): - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = serializer.validated_data['content_object'] - codename = serializer.validated_data['permission'].codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - serializer.save() - - def perform_destroy(self, instance): - # Only directly-applied permissions may be modified; forbid deleting - # permissions inherited from ancestors - if instance.inherited: - raise exceptions.MethodNotAllowed( - self.request.method, - detail='Cannot delete inherited permissions.' - ) - # Make sure the requesting user has the share_ permission on - # the affected object - with transaction.atomic(): - affected_object = instance.content_object - codename = instance.permission.codename - if not self._requesting_user_can_share(affected_object, codename): - raise exceptions.PermissionDenied() - instance.content_object.remove_perm( - instance.user, - instance.permission.codename - ) - - -class CollectionViewSet(viewsets.ModelViewSet): - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Collection.objects.select_related( - 'owner', 'parent' - ).prefetch_related( - 'permissions', - 'permissions__permission', - 'permissions__user', - 'permissions__content_object', - 'usercollectionsubscription_set', - ).all().order_by('-date_modified') - serializer_class = CollectionSerializer - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - lookup_field = 'uid' - - def _clone(self): - # Clone an existing collection. - original_uid = self.request.data[CLONE_ARG_NAME] - original_collection = get_object_or_404(Collection, uid=original_uid) - view_perm = get_perm_name('view', original_collection) - if not self.request.user.has_perm(view_perm, original_collection): - raise Http404 - else: - # Copy the essential data from the original collection. - original_data= model_to_dict(original_collection) - cloned_data= {keep_field: original_data[keep_field] - for keep_field in COLLECTION_CLONE_FIELDS} - if original_collection.tag_string: - cloned_data['tag_string']= original_collection.tag_string - - # Pull any additionally provided parameters/overrides from the - # request. - for param in self.request.data: - cloned_data[param] = self.request.data[param] - serializer = self.get_serializer(data=cloned_data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME not in request.data: - return super(CollectionViewSet, self).create(request, *args, - **kwargs) - else: - return self._clone() - - def perform_create(self, serializer): - serializer.save(owner=self.request.user) - - def perform_update(self, serializer, *args, **kwargs): - ''' Only the owner is allowed to change `discoverable_when_public` ''' - original_collection = self.get_object() - if (self.request.user != original_collection.owner and - 'discoverable_when_public' in serializer.validated_data and - (serializer.validated_data['discoverable_when_public'] != - original_collection.discoverable_when_public) - ): - raise exceptions.PermissionDenied() - - # Some fields shouldn't affect the modification date - FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set(( - 'discoverable_when_public', - )) - changed_fields = set() - for k, v in serializer.validated_data.iteritems(): - if getattr(original_collection, k) != v: - changed_fields.add(k) - if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE): - with disable_auto_field_update(Collection, 'date_modified'): - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - return super(CollectionViewSet, self).perform_update( - serializer, *args, **kwargs) - - def perform_destroy(self, instance): - instance.delete_with_deferred_indexing() - - def get_serializer_class(self): - if self.action == 'list': - return CollectionListSerializer - else: - return CollectionSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - ''' - return all ids of tags which are tagged to items of the given - content_type - ''' - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() - - -class CurrentUserViewSet(viewsets.ModelViewSet): - queryset = User.objects.none() - serializer_class = CurrentUserSerializer - - def get_object(self): - return self.request.user - - -class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin, - viewsets.GenericViewSet): - authentication_classes = [ApplicationTokenAuthentication] - queryset = User.objects.all() - serializer_class = CreateUserSerializer - lookup_field = 'username' - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # users via this endpoint - raise exceptions.PermissionDenied() - return super(AuthorizedApplicationUserViewSet, self).create( - request, *args, **kwargs) - - -@api_view(['POST']) -@authentication_classes([ApplicationTokenAuthentication]) -def authorized_application_authenticate_user(request): - ''' Returns a user-level API token when given a valid username and - password. The request header must include an authorized application key ''' - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to authenticate - # users this way - raise exceptions.PermissionDenied() - serializer = AuthorizedApplicationUserSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - username = serializer.validated_data['username'] - password = serializer.validated_data['password'] - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - raise exceptions.PermissionDenied() - if not user.is_active or not user.check_password(password): - raise exceptions.PermissionDenied() - token = Token.objects.get_or_create(user=user)[0] - response_data = {'token': token.key} - user_attributes_to_return = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'is_staff', - 'is_active', - 'is_superuser', - 'last_login', - 'date_joined' - ) - for attribute in user_attributes_to_return: - response_data[attribute] = getattr(user, attribute) - return Response(response_data) - - -class OneTimeAuthenticationKeyViewSet( - mixins.CreateModelMixin, - viewsets.GenericViewSet -): - authentication_classes = [ApplicationTokenAuthentication] - queryset = OneTimeAuthenticationKey.objects.none() - serializer_class = OneTimeAuthenticationKeySerializer - - def create(self, request, *args, **kwargs): - if type(request.auth) is not AuthorizedApplication: - # Only specially-authorized applications are allowed to create - # one-time authentication keys via this endpoint - raise exceptions.PermissionDenied() - return super(OneTimeAuthenticationKeyViewSet, self).create( - request, *args, **kwargs) - - -@require_POST -@csrf_exempt -def one_time_login(request): - ''' If the request provides a key that matches a OneTimeAuthenticationKey - object, log in the User specified in that object and redirect to the - location specified in the 'next' parameter ''' - try: - key = request.POST['key'] - except KeyError: - return HttpResponseBadRequest(_('No key provided')) - try: - next_ = request.GET['next'] - except KeyError: - next_ = None - if not next_ or not is_safe_url(url=next_, host=request.get_host()): - next_ = resolve_url(settings.LOGIN_REDIRECT_URL) - # Clean out all expired keys, just to keep the database tidier - OneTimeAuthenticationKey.objects.filter( - expiry__lt=datetime.datetime.now()).delete() - with transaction.atomic(): - try: - otak = OneTimeAuthenticationKey.objects.get( - key=key, - expiry__gte=datetime.datetime.now() - ) - except OneTimeAuthenticationKey.DoesNotExist: - return HttpResponseBadRequest(_('Invalid or expired key')) - # Nevermore - otak.delete() - # The request included a valid one-time key. Log in the associated user - user = otak.user - user.backend = settings.AUTHENTICATION_BACKENDS[0] - login(request, user) - return HttpResponseRedirect(next_) - - -class XlsFormParser(MultiPartParser): - pass - - -class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet): - queryset = ImportTask.objects.all() - serializer_class = ImportTaskSerializer - lookup_field = 'uid' - - def get_serializer_class(self): - if self.action == 'list': - return ImportTaskListSerializer - else: - return ImportTaskSerializer - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ImportTask.objects.none() - else: - return ImportTask.objects.filter( - user=self.request.user).order_by('date_created') - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - itask_data = { - 'library': request.POST.get('library') not in ['false', False], - # NOTE: 'filename' here comes from 'name' (!) in the POST data - 'filename': request.POST.get('name', None), - 'destination': request.POST.get('destination', None), - } - if 'base64Encoded' in request.POST: - encoded_str = request.POST['base64Encoded'] - encoded_substr = encoded_str[encoded_str.index('base64') + 7:] - itask_data['base64Encoded'] = encoded_substr - elif 'file' in request.data: - encoded_xls = base64.b64encode(request.data['file'].read()) - itask_data['base64Encoded'] = encoded_xls - if 'filename' not in itask_data: - itask_data['filename'] = request.data['file'].name - elif 'url' in request.POST: - itask_data['single_xls_url'] = request.POST['url'] - import_task = ImportTask.objects.create(user=request.user, - data=itask_data) - # Have Celery run the import in the background - import_in_background.delay(import_task_uid=import_task.uid) - return Response({ - 'uid': import_task.uid, - 'url': reverse( - 'importtask-detail', - kwargs={'uid': import_task.uid}, - request=request), - 'status': ImportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class ExportTaskViewSet(NoUpdateModelViewSet): - queryset = ExportTask.objects.all() - serializer_class = ExportTaskSerializer - lookup_field = 'uid' - - def get_queryset(self, *args, **kwargs): - if self.request.user.is_anonymous(): - return ExportTask.objects.none() - - queryset = ExportTask.objects.filter( - user=self.request.user).order_by('date_created') - - # Ultra-basic filtering by: - # * source URL or UID if `q=source:[URL|UID]` was provided; - # * comma-separated list of `ExportTask` UIDs if - # `q=uid__in:[UID],[UID],...` was provided - q = self.request.query_params.get('q', False) - if not q: - # No filter requested - return queryset - if q.startswith('source:'): - q = remove_string_prefix(q, 'source:') - # This is exceedingly crude... but support for querying inside - # JSONField not available until Django 1.9 - queryset = queryset.filter(data__contains=q) - elif q.startswith('uid__in:'): - q = remove_string_prefix(q, 'uid__in:') - uids = [uid.strip() for uid in q.split(',')] - queryset = queryset.filter(uid__in=uids) - else: - # Filter requested that we don't understand; make it obvious by - # returning nothing - return ExportTask.objects.none() - return queryset - - def create(self, request, *args, **kwargs): - if self.request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - # Read valid options from POST data - valid_options = ( - 'type', - 'source', - 'group_sep', - 'lang', - 'hierarchy_in_labels', - 'fields_from_all_versions', - ) - task_data = {} - for opt in valid_options: - opt_val = request.POST.get(opt, None) - if opt_val is not None: - task_data[opt] = opt_val - # Complain if no source was specified - if not task_data.get('source', False): - raise exceptions.ValidationError( - {'source': 'This field is required.'}) - # Get the source object - source_type, source = _resolve_url_to_asset_or_collection( - task_data['source']) - # Complain if it's not an Asset - if source_type != 'asset': - raise exceptions.ValidationError( - {'source': 'This field must specify an asset.'}) - # Complain if it's not deployed - if not source.has_deployment: - raise exceptions.ValidationError( - {'source': 'The specified asset must be deployed.'}) - # Create a new export task - export_task = ExportTask.objects.create(user=request.user, - data=task_data) - # Have Celery run the export in the background - export_in_background.delay(export_task_uid=export_task.uid) - return Response({ - 'uid': export_task.uid, - 'url': reverse( - 'exporttask-detail', - kwargs={'uid': export_task.uid}, - request=request), - 'status': ExportTask.PROCESSING - }, status.HTTP_201_CREATED) - - -class AssetSnapshotViewSet(NoUpdateModelViewSet): - serializer_class = AssetSnapshotSerializer - lookup_field = 'uid' - queryset = AssetSnapshot.objects.all() - - renderer_classes = NoUpdateModelViewSet.renderer_classes + [ - XMLRenderer, - ] - - def filter_queryset(self, queryset): - if (self.action == 'retrieve' and - self.request.accepted_renderer.format == 'xml'): - # The XML renderer is totally public and serves anyone, so - # /asset_snapshot/valid_uid.xml is world-readable, even though - # /asset_snapshot/valid_uid/ requires ownership. Return the - # queryset unfiltered - return queryset - else: - user = self.request.user - owned_snapshots = queryset.none() - if not user.is_anonymous(): - owned_snapshots = queryset.filter(owner=user) - return owned_snapshots | RelatedAssetPermissionsFilter( - ).filter_queryset(self.request, queryset, view=self) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def xform(self, request, *args, **kwargs): - ''' - This route will render the XForm into syntax-highlighted HTML. - It is useful for debugging pyxform transformations - ''' - snapshot = self.get_object() - response_data = copy.copy(snapshot.details) - options = { - 'linenos': True, - 'full': True, - } - if snapshot.xml != '': - response_data['highlighted_xform'] = highlight_xform(snapshot.xml, - **options) - return Response(response_data, template_name='highlighted_xform.html') - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def preview(self, request, *args, **kwargs): - snapshot = self.get_object() - if snapshot.details.get('status') == 'success': - preview_url = "{}{}?form={}".format( - settings.ENKETO_SERVER, - settings.ENKETO_PREVIEW_URI, - reverse(viewname='assetsnapshot-detail', - format='xml', - kwargs={'uid': snapshot.uid}, - request=request, - ), - ) - return HttpResponseRedirect(preview_url) - else: - response_data = copy.copy(snapshot.details) - return Response(response_data, template_name='preview_error.html') - - -class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet): - model = AssetFile - lookup_field = 'uid' - filter_backends = (RelatedAssetPermissionsFilter,) - serializer_class = AssetFileSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - return _queryset - - def perform_create(self, serializer): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - serializer.save( - asset=asset, - user=self.request.user - ) - - def perform_destroy(self, *args, **kwargs): - asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset']) - if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset): - raise exceptions.PermissionDenied() - return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs) - - class PrivateContentView(PrivateStorageDetailView): - model = AssetFile - model_file_field = 'content' - def can_access_file(self, private_file): - return private_file.request.user.has_perm( - PERM_VIEW_ASSET, private_file.parent_object.asset) - - @detail_route(methods=['get']) - def content(self, *args, **kwargs): - view = self.PrivateContentView.as_view( - model=AssetFile, - slug_url_kwarg='uid', - slug_field='uid', - model_file_field='content' - ) - af = self.get_object() - # TODO: simply redirect if external storage with expiring tokens (e.g. - # Amazon S3) is used? - # return HttpResponseRedirect(af.content.url) - return view(self.request, uid=af.uid) - - -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer +from kpi.models.object_permission import get_anonymous_user +from kpi.serializers import UserCollectionSubscriptionSerializer class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): @@ -1568,71 +28,3 @@ def get_queryset(self): def perform_create(self, serializer): serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) From 4a2361d76097c03b20477ea6a460456116d78671 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 07:50:00 -0400 Subject: [PATCH 026/499] Removed unused imports --- kpi/serializers.py | 4 ++-- kpi/urls.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/kpi/serializers.py b/kpi/serializers.py index bc7ff6a1b0..aa5415aec1 100644 --- a/kpi/serializers.py +++ b/kpi/serializers.py @@ -37,8 +37,8 @@ from .forms import USERNAME_INVALID_MESSAGE from .utils.gravatar_url import gravatar_url -from .deployment_backends.kc_access.utils import get_kc_profile_data -from .deployment_backends.kc_access.utils import set_kc_require_auth +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth class Paginated(LimitOffsetPagination): diff --git a/kpi/urls.py b/kpi/urls.py index 42202d89b4..352698f16f 100644 --- a/kpi/urls.py +++ b/kpi/urls.py @@ -1,7 +1,6 @@ from django.conf.urls import url, include from django.views.i18n import javascript_catalog from hub.views import ExtraDetailRegistrationView -from rest_framework.routers import DefaultRouter from rest_framework_extensions.routers import ExtendedDefaultRouter import private_storage.urls @@ -9,7 +8,6 @@ from hub.views import switch_builder from kobo.apps.hook.views import HookViewSet, HookLogViewSet from kobo.apps.reports.views import ReportsViewSet -from kobo.apps.superuser_stats.views import user_report, retrieve_user_report from kpi.forms import RegistrationForm from kpi.views import ( AssetViewSet, From 1e3706c1e40797047e9f993fd3deff08abdaa1e0 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 08:16:43 -0400 Subject: [PATCH 027/499] WIP - split serializers --- kpi/serializers/__init__.py | 1253 ++++++++++++++++ .../ancestor_collections.py} | 25 +- kpi/serializers/asset.py | 1263 +++++++++++++++++ kpi/serializers/asset_file.py | 1263 +++++++++++++++++ kpi/serializers/asset_snapshot.py | 1263 +++++++++++++++++ kpi/serializers/asset_url_list.py | 1263 +++++++++++++++++ kpi/serializers/asset_version.py | 1263 +++++++++++++++++ .../authorized_application_user.py | 1263 +++++++++++++++++ kpi/serializers/collection.py | 1263 +++++++++++++++++ kpi/serializers/collection_children.py | 1263 +++++++++++++++++ kpi/serializers/create_user.py | 1237 ++++++++++++++++ kpi/serializers/current_user.py | 1178 +++++++++++++++ kpi/serializers/deployment.py | 1263 +++++++++++++++++ kpi/serializers/export_task.py | 1263 +++++++++++++++++ .../fields/generic_hyperlinked_related.py | 1263 +++++++++++++++++ kpi/serializers/fields/read_only.py | 1263 +++++++++++++++++ .../relative_prefix_hyperlinked_related.py | 1263 +++++++++++++++++ kpi/serializers/fields/writeable_json.py | 1263 +++++++++++++++++ kpi/serializers/import_task.py | 1263 +++++++++++++++++ kpi/serializers/object_permission.py | 1263 +++++++++++++++++ kpi/serializers/object_permission_nested.py | 1263 +++++++++++++++++ .../one_time_authentication_key.py | 1263 +++++++++++++++++ kpi/serializers/sitewide_message.py | 1263 +++++++++++++++++ kpi/serializers/tag.py | 1263 +++++++++++++++++ kpi/serializers/user.py | 1263 +++++++++++++++++ .../user_collection_subscription.py | 1263 +++++++++++++++++ 26 files changed, 31473 insertions(+), 6 deletions(-) rename kpi/{serializers.py => serializers/ancestor_collections.py} (98%) create mode 100644 kpi/serializers/asset.py create mode 100644 kpi/serializers/asset_file.py create mode 100644 kpi/serializers/asset_snapshot.py create mode 100644 kpi/serializers/asset_url_list.py create mode 100644 kpi/serializers/asset_version.py create mode 100644 kpi/serializers/authorized_application_user.py create mode 100644 kpi/serializers/collection.py create mode 100644 kpi/serializers/collection_children.py create mode 100644 kpi/serializers/deployment.py create mode 100644 kpi/serializers/export_task.py create mode 100644 kpi/serializers/fields/generic_hyperlinked_related.py create mode 100644 kpi/serializers/fields/read_only.py create mode 100644 kpi/serializers/fields/relative_prefix_hyperlinked_related.py create mode 100644 kpi/serializers/fields/writeable_json.py create mode 100644 kpi/serializers/import_task.py create mode 100644 kpi/serializers/object_permission.py create mode 100644 kpi/serializers/object_permission_nested.py create mode 100644 kpi/serializers/one_time_authentication_key.py create mode 100644 kpi/serializers/sitewide_message.py create mode 100644 kpi/serializers/tag.py create mode 100644 kpi/serializers/user.py create mode 100644 kpi/serializers/user_collection_subscription.py diff --git a/kpi/serializers/__init__.py b/kpi/serializers/__init__.py index d08d385b1b..9d3075d396 100644 --- a/kpi/serializers/__init__.py +++ b/kpi/serializers/__init__.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from .v1.ancestor_collections import AncestorCollectionsSerializer from .v1.asset import AssetListSerializer @@ -25,3 +26,1255 @@ from .current_user import CurrentUserSerializer from .v1.user import UserSerializer from .v1.user_collection_subscription import UserCollectionSubscriptionSerializer +======= +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous: + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous: + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous: + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + rep['extra_details']['can_publicize_collection'] = obj.has_perm( + 'kpi.publicize_collection' + ) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') +>>>>>>> WIP - split serializers diff --git a/kpi/serializers.py b/kpi/serializers/ancestor_collections.py similarity index 98% rename from kpi/serializers.py rename to kpi/serializers/ancestor_collections.py index aa5415aec1..699ab0a071 100644 --- a/kpi/serializers.py +++ b/kpi/serializers/ancestor_collections.py @@ -18,6 +18,8 @@ from rest_framework.reverse import reverse_lazy, reverse from taggit.models import Tag +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY from hub.models import SitewideMessage, ExtraUserDetail from .fields import PaginatedApiField, SerializerMethodFileField from .models import Asset @@ -193,7 +195,7 @@ def _get_assets(self, obj): # Check if the user is anonymous. The # django.contrib.auth.models.AnonymousUser object doesn't work for # queries. - if user.is_anonymous: + if user.is_anonymous(): user = get_anonymous_user() return [reverse('asset-detail', args=(sa.uid,), request=request) for sa in Asset.objects.filter(tags=obj, owner=user).all()] @@ -204,7 +206,7 @@ def _get_collections(self, obj): # Check if the user is anonymous. The # django.contrib.auth.models.AnonymousUser object doesn't work for # queries. - if user.is_anonymous: + if user.is_anonymous(): user = get_anonymous_user() return [reverse('collection-detail', args=(coll.uid,), request=request) for coll in Collection.objects.filter(tags=obj, owner=user) @@ -598,7 +600,7 @@ def get_fields(self, *args, **kwargs): # Check if the user is anonymous. The # django.contrib.auth.models.AnonymousUser object doesn't work for # queries. - if user.is_anonymous: + if user.is_anonymous(): user = get_anonymous_user() if 'parent' in fields: # TODO: remove this restriction? @@ -914,6 +916,7 @@ class CurrentUserSerializer(serializers.ModelSerializer): date_joined = serializers.SerializerMethodField() projects_url = serializers.SerializerMethodField() gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() extra_details = WritableJSONField(source='extra_details.data') current_password = serializers.CharField(write_only=True, required=False) new_password = serializers.CharField(write_only=True, required=False) @@ -933,6 +936,7 @@ class Meta: 'gravatar', 'is_staff', 'last_login', + 'languages', 'extra_details', 'current_password', 'new_password', @@ -954,6 +958,9 @@ def get_projects_url(self, obj): def get_gravatar(self, obj): return gravatar_url(obj.email) + def get_languages(self, obj): + return settings.LANGUAGES + def get_git_rev(self, obj): request = self.context.get('request', False) if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): @@ -965,15 +972,21 @@ def to_representation(self, obj): if obj.is_anonymous(): return {'message': 'user is not logged in'} rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES if not rep['extra_details']: rep['extra_details'] = {} # `require_auth` needs to be read from KC every time if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: rep['extra_details']['require_auth'] = get_kc_profile_data( obj.pk).get('require_auth', False) - rep['extra_details']['can_publicize_collection'] = obj.has_perm( - 'kpi.publicize_collection' - ) return rep diff --git a/kpi/serializers/asset.py b/kpi/serializers/asset.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/asset.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_file.py b/kpi/serializers/asset_file.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/asset_file.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_snapshot.py b/kpi/serializers/asset_snapshot.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/asset_snapshot.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_url_list.py b/kpi/serializers/asset_url_list.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/asset_url_list.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_version.py b/kpi/serializers/asset_version.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/asset_version.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/authorized_application_user.py b/kpi/serializers/authorized_application_user.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/authorized_application_user.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/collection.py b/kpi/serializers/collection.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/collection.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/collection_children.py b/kpi/serializers/collection_children.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/collection_children.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/create_user.py b/kpi/serializers/create_user.py index 1f49ec3afd..9a347d4621 100644 --- a/kpi/serializers/create_user.py +++ b/kpi/serializers/create_user.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from django.contrib.auth.models import User from rest_framework import serializers @@ -5,6 +6,1032 @@ from kpi.forms import USERNAME_REGEX from kpi.forms import USERNAME_MAX_LENGTH from kpi.forms import USERNAME_INVALID_MESSAGE +======= +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) +>>>>>>> WIP - split serializers class CreateUserSerializer(serializers.ModelSerializer): @@ -14,7 +1041,10 @@ class CreateUserSerializer(serializers.ModelSerializer): error_messages={'invalid': USERNAME_INVALID_MESSAGE} ) email = serializers.EmailField() +<<<<<<< HEAD +======= +>>>>>>> WIP - split serializers class Meta: model = User fields = ( @@ -23,6 +1053,12 @@ class Meta: 'first_name', 'last_name', 'email', +<<<<<<< HEAD +======= + #'is_staff', + #'is_superuser', + #'is_active', +>>>>>>> WIP - split serializers ) extra_kwargs = { 'password': {'write_only': True}, @@ -44,3 +1080,204 @@ def create(self, validated_data): pass user.save() return user +<<<<<<< HEAD +======= + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') +>>>>>>> WIP - split serializers diff --git a/kpi/serializers/current_user.py b/kpi/serializers/current_user.py index 38cd82b0b6..54d5eb1b52 100644 --- a/kpi/serializers/current_user.py +++ b/kpi/serializers/current_user.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 import datetime import pytz @@ -14,6 +15,918 @@ from kpi.deployment_backends.kc_access.utils import set_kc_require_auth from kpi.fields import WritableJSONField from kpi.utils.gravatar_url import gravatar_url +======= +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } +>>>>>>> WIP - split serializers class CurrentUserSerializer(serializers.ModelSerializer): @@ -75,9 +988,24 @@ def get_git_rev(self, obj): return False def to_representation(self, obj): +<<<<<<< HEAD if obj.is_anonymous: return {'message': 'user is not logged in'} rep = super().to_representation(obj) +======= + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES +>>>>>>> WIP - split serializers if not rep['extra_details']: rep['extra_details'] = {} # `require_auth` needs to be read from KC every time @@ -108,17 +1036,267 @@ def update(self, instance, validated_data): if instance.check_password(current_password): instance.set_password(new_password) instance.save() +<<<<<<< HEAD request = self.context.get('request', False) if request: update_session_auth_hash(request, instance) +======= +>>>>>>> WIP - split serializers else: raise serializers.ValidationError({ 'current_password': 'Incorrect current password.' }) elif any((current_password, new_password)): raise serializers.ValidationError( +<<<<<<< HEAD 'current_password and new_password must both be sent ' 'together; one or the other cannot be sent individually.' ) return super().update( instance, validated_data) +======= + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') +>>>>>>> WIP - split serializers diff --git a/kpi/serializers/deployment.py b/kpi/serializers/deployment.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/deployment.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/export_task.py b/kpi/serializers/export_task.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/export_task.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/generic_hyperlinked_related.py b/kpi/serializers/fields/generic_hyperlinked_related.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/fields/generic_hyperlinked_related.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/read_only.py b/kpi/serializers/fields/read_only.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/fields/read_only.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/relative_prefix_hyperlinked_related.py b/kpi/serializers/fields/relative_prefix_hyperlinked_related.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/fields/relative_prefix_hyperlinked_related.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/writeable_json.py b/kpi/serializers/fields/writeable_json.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/fields/writeable_json.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/import_task.py b/kpi/serializers/import_task.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/import_task.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/object_permission.py b/kpi/serializers/object_permission.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/object_permission.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/object_permission_nested.py b/kpi/serializers/object_permission_nested.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/object_permission_nested.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/one_time_authentication_key.py b/kpi/serializers/one_time_authentication_key.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/one_time_authentication_key.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/sitewide_message.py b/kpi/serializers/sitewide_message.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/sitewide_message.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/tag.py b/kpi/serializers/tag.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/tag.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/user.py b/kpi/serializers/user.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/user.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/user_collection_subscription.py b/kpi/serializers/user_collection_subscription.py new file mode 100644 index 0000000000..699ab0a071 --- /dev/null +++ b/kpi/serializers/user_collection_subscription.py @@ -0,0 +1,1263 @@ +# -*- coding: utf-8 -*- +import datetime +import json +import pytz +from collections import OrderedDict + +import constance +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.db import transaction +from django.db.utils import ProgrammingError +from django.utils.six.moves.urllib import parse as urlparse +from django.conf import settings +from rest_framework import serializers, exceptions +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from rest_framework.reverse import reverse_lazy, reverse +from taggit.models import Tag + +from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES +from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY +from hub.models import SitewideMessage, ExtraUserDetail +from .fields import PaginatedApiField, SerializerMethodFileField +from .models import Asset +from .models import AssetSnapshot +from .models import AssetVersion +from .models import AssetFile +from .models import Collection +from .models import CollectionChildrenQuerySet +from .models import UserCollectionSubscription +from .models import ImportTask, ExportTask +from .models import ObjectPermission +from .models.object_permission import get_anonymous_user, get_objects_for_user +from .models.asset import ASSET_TYPES +from .models import TagUid +from .models import OneTimeAuthenticationKey +from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH +from .forms import USERNAME_INVALID_MESSAGE +from .utils.gravatar_url import gravatar_url + +from kpi.deployment_backends.kc_access.utils import get_kc_profile_data +from kpi.deployment_backends.kc_access.utils import set_kc_require_auth + + +class Paginated(LimitOffsetPagination): + + """ Adds 'root' to the wrapping response object. """ + root = serializers.SerializerMethodField('get_parent_url', read_only=True) + + def get_parent_url(self, obj): + return reverse_lazy('api-root', request=self.context.get('request')) + + +class TinyPaginated(PageNumberPagination): + """ + Same as Paginated with a small page size + """ + page_size = 50 + + +class WritableJSONField(serializers.Field): + + """ Serializer for JSONField -- required to make field writable""" + + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super(WritableJSONField, self).__init__(**kwargs) + + def to_internal_value(self, data): + if (not data) and (not self.required): + return None + else: + try: + return json.loads(data) + except Exception as e: + raise serializers.ValidationError( + u'Unable to parse JSON: {}'.format(e)) + + def to_representation(self, value): + return value + + +class ReadOnlyJSONField(serializers.ReadOnlyField): + def to_representation(self, value): + return value + + +class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): + + def __init__(self, **kwargs): + # These arguments are required by ancestors but meaningless in our + # situation. We will override them dynamically. + kwargs['view_name'] = '*' + kwargs['queryset'] = ObjectPermission.objects.none() + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) + + def to_representation(self, value): + self.view_name = '{}-detail'.format( + ContentType.objects.get_for_model(value).model) + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) + self.view_name = '*' + return result + + def to_internal_value(self, data): + ''' The vast majority of this method has been copied and pasted from + HyperlinkedRelatedField.to_internal_value(). Modifications exist + to allow any type of object. ''' + _ = self.context.get('request', None) + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + try: + match = resolve(data) + except Resolver404: + self.fail('no_match') + + ### Begin modifications ### + # We're a generic relation; we don't discriminate + ''' + try: + expected_viewname = request.versioning_scheme.get_versioned_viewname( + self.view_name, request + ) + except AttributeError: + expected_viewname = self.view_name + + if match.view_name != expected_viewname: + self.fail('incorrect_match') + ''' + + # Dynamically modify the queryset + self.queryset = match.func.cls.queryset + ### End modifications ### + + try: + return self.get_object(match.view_name, match.args, match.kwargs) + except (ObjectDoesNotExist, TypeError, ValueError): + self.fail('does_not_exist') + + +class RelativePrefixHyperlinkedRelatedField( + serializers.HyperlinkedRelatedField): + def to_internal_value(self, data): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + # The script prefix must be removed even if the URL is relative. + # TODO: Figure out why DRF only strips absolute URLs, or file bug + if True or http_prefix: + # If needed convert absolute URLs to relative path + data = urlparse.urlparse(data).path + prefix = get_script_prefix() + if data.startswith(prefix): + data = '/' + data[len(prefix):] + + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) + + +class TagSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField('_get_tag_url', read_only=True) + assets = serializers.SerializerMethodField('_get_assets', read_only=True) + collections = serializers.SerializerMethodField( + '_get_collections', read_only=True) + parent = serializers.SerializerMethodField( + '_get_parent_url', read_only=True) + uid = serializers.ReadOnlyField(source='taguid.uid') + + class Meta: + model = Tag + fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') + + def _get_parent_url(self, obj): + return reverse('tag-list', request=self.context.get('request', None)) + + def _get_assets(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('asset-detail', args=(sa.uid,), request=request) + for sa in Asset.objects.filter(tags=obj, owner=user).all()] + + def _get_collections(self, obj): + request = self.context.get('request', None) + user = request.user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + return [reverse('collection-detail', args=(coll.uid,), request=request) + for coll in Collection.objects.filter(tags=obj, owner=user) + .all()] + + def _get_tag_url(self, obj): + request = self.context.get('request', None) + uid = TagUid.objects.get_or_create(tag=obj)[0].uid + return reverse('tag-detail', args=(uid,), request=request) + + +class TagListSerializer(TagSerializer): + + class Meta: + model = Tag + fields = ('name', 'url', ) + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + #'content_object', + 'deny', + 'inherited', + ) + + +class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + + class Meta: + model = Collection + fields = ('name', 'uid', 'url') + + +class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='assetsnapshot-detail') + uid = serializers.ReadOnlyField() + xml = serializers.SerializerMethodField() + enketopreviewlink = serializers.SerializerMethodField() + details = WritableJSONField(required=False) + asset = RelativePrefixHyperlinkedRelatedField( + queryset=Asset.objects.all(), view_name='asset-detail', + lookup_field='uid', + required=False, + allow_null=True, + style={'base_template': 'input.html'} # Render as a simple text box + ) + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + asset_version_id = serializers.ReadOnlyField() + date_created = serializers.DateTimeField(read_only=True) + source = WritableJSONField(required=False) + + def get_xml(self, obj): + ''' There's too much magic in HyperlinkedIdentityField. When format is + unspecified by the request, HyperlinkedIdentityField.to_representation() + refuses to append format to the url. We want to *unconditionally* + include the xml format suffix. ''' + return reverse( + viewname='assetsnapshot-detail', format='xml', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def get_enketopreviewlink(self, obj): + return reverse( + viewname='assetsnapshot-preview', + kwargs={'uid': obj.uid}, + request=self.context.get('request', None) + ) + + def create(self, validated_data): + ''' Create a snapshot of an asset, either by copying an existing + asset's content or by accepting the source directly in the request. + Transform the source into XML that's then exposed to Enketo + (and the www). ''' + asset = validated_data.get('asset', None) + source = validated_data.get('source', None) + + # Force owner to be the requesting user + # NB: validated_data is not used when linking to an existing asset + # without specifying source; in that case, the snapshot owner is the + # asset's owner, even if a different user makes the request + validated_data['owner'] = self.context['request'].user + + # TODO: Move to a validator? + if asset and source: + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + validated_data['source'] = source + snapshot = AssetSnapshot.objects.create(**validated_data) + elif asset: + # The client provided an existing asset; read source from it + if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): + # The client is not allowed to snapshot this asset + raise exceptions.PermissionDenied + # asset.snapshot pulls , by default, a snapshot for the latest + # version. + snapshot = asset.snapshot + elif source: + # The client provided source directly; no need to copy anything + # For tidiness, pop off unused fields. `None` avoids KeyError + validated_data.pop('asset', None) + validated_data.pop('asset_version', None) + snapshot = AssetSnapshot.objects.create(**validated_data) + else: + raise serializers.ValidationError('Specify an asset and/or a source') + + if not snapshot.xml: + raise serializers.ValidationError(snapshot.details) + return snapshot + + class Meta: + model = AssetSnapshot + lookup_field = 'uid' + fields = ('url', + 'uid', + 'owner', + 'date_created', + 'xml', + 'enketopreviewlink', + 'asset', + 'asset_version_id', + 'details', + 'source', + ) + + +class AssetFileSerializer(serializers.ModelSerializer): + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + asset = RelativePrefixHyperlinkedRelatedField( + view_name='asset-detail', lookup_field='uid', read_only=True) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + user__username = serializers.ReadOnlyField(source='user.username') + file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) + name = serializers.CharField() + date_created = serializers.ReadOnlyField() + content = SerializerMethodFileField() + metadata = WritableJSONField(required=False) + + def get_url(self, obj): + return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + def get_content(self, obj, *args, **kwargs): + return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + class Meta: + model = AssetFile + fields = ( + 'uid', + 'url', + 'asset', + 'user', + 'user__username', + 'file_type', + 'name', + 'date_created', + 'content', + 'metadata', + ) + + +class AssetVersionListSerializer(serializers.Serializer): + # If you change these fields, please update the `only()` and + # `select_related()` calls in `AssetVersionViewSet.get_queryset()` + uid = serializers.ReadOnlyField() + url = serializers.SerializerMethodField() + content_hash = serializers.ReadOnlyField() + date_deployed = serializers.SerializerMethodField(read_only=True) + date_modified = serializers.CharField(read_only=True) + + def get_date_deployed(self, obj): + return obj.deployed and obj.date_modified + + def get_url(self, obj): + return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), + request=self.context.get('request', None)) + + +class AssetVersionSerializer(AssetVersionListSerializer): + content = serializers.SerializerMethodField(read_only=True) + + def get_content(self, obj): + return obj.version_content + + def get_version_id(self, obj): + return obj.uid + + class Meta: + model = AssetVersion + fields = ( + 'version_id', + 'date_deployed', + 'date_modified', + 'content_hash', + 'content', + ) + + +class AssetSerializer(serializers.HyperlinkedModelSerializer): + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', lookup_field='username', read_only=True) + owner__username = serializers.ReadOnlyField(source='owner.username') + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='asset-detail') + asset_type = serializers.ChoiceField(choices=ASSET_TYPES) + settings = WritableJSONField(required=False, allow_blank=True) + content = WritableJSONField(required=False) + report_styles = WritableJSONField(required=False) + report_custom = WritableJSONField(required=False) + map_styles = WritableJSONField(required=False) + map_custom = WritableJSONField(required=False) + xls_link = serializers.SerializerMethodField() + summary = serializers.ReadOnlyField() + koboform_link = serializers.SerializerMethodField() + xform_link = serializers.SerializerMethodField() + version_count = serializers.SerializerMethodField() + downloads = serializers.SerializerMethodField() + embeds = serializers.SerializerMethodField() + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + queryset=Collection.objects.all(), + view_name='collection-detail', + required=False, + allow_null=True + ) + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) + tag_string = serializers.CharField(required=False, allow_blank=True) + version_id = serializers.CharField(read_only=True) + version__content_hash = serializers.CharField(read_only=True) + has_deployment = serializers.ReadOnlyField() + deployed_version_id = serializers.SerializerMethodField() + deployed_versions = PaginatedApiField( + serializer_class=AssetVersionListSerializer, + # Higher-than-normal limit since the client doesn't yet know how to + # request more than the first page + default_limit=100 + ) + deployment__identifier = serializers.SerializerMethodField() + deployment__active = serializers.SerializerMethodField() + deployment__links = serializers.SerializerMethodField() + deployment__data_download_links = serializers.SerializerMethodField() + deployment__submission_count = serializers.SerializerMethodField() + + # Only add link instead of hooks list to avoid multiple access to DB. + hooks_link = serializers.SerializerMethodField() + + class Meta: + model = Asset + lookup_field = 'uid' + fields = ('url', + 'owner', + 'owner__username', + 'parent', + 'ancestors', + 'settings', + 'asset_type', + 'date_created', + 'summary', + 'date_modified', + 'version_id', + 'version__content_hash', + 'version_count', + 'has_deployment', + 'deployed_version_id', + 'deployed_versions', + 'deployment__identifier', + 'deployment__links', + 'deployment__active', + 'deployment__data_download_links', + 'deployment__submission_count', + 'report_styles', + 'report_custom', + 'map_styles', + 'map_custom', + 'content', + 'downloads', + 'embeds', + 'koboform_link', + 'xform_link', + 'hooks_link', + 'tag_string', + 'uid', + 'kind', + 'xls_link', + 'name', + 'permissions', + 'settings',) + extra_kwargs = { + 'parent': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def update(self, asset, validated_data): + asset_content = asset.content + _req_data = self.context['request'].data + _has_translations = 'translations' in _req_data + _has_content = 'content' in _req_data + if _has_translations and not _has_content: + translations_list = json.loads(_req_data['translations']) + try: + asset.update_translation_list(translations_list) + except ValueError as err: + raise serializers.ValidationError(err.message) + validated_data['content'] = asset_content + return super(AssetSerializer, self).update(asset, validated_data) + + def get_fields(self, *args, **kwargs): + fields = super(AssetSerializer, self).get_fields(*args, **kwargs) + user = self.context['request'].user + # Check if the user is anonymous. The + # django.contrib.auth.models.AnonymousUser object doesn't work for + # queries. + if user.is_anonymous(): + user = get_anonymous_user() + if 'parent' in fields: + # TODO: remove this restriction? + fields['parent'].queryset = fields['parent'].queryset.filter( + owner=user) + # Honor requests to exclude fields + # TODO: Actually exclude fields from tha database query! DRF grabs + # all columns, even ones that are never named in `fields` + excludes = self.context['request'].GET.get('exclude', '') + for exclude in excludes.split(','): + exclude = exclude.strip() + if exclude in fields: + fields.pop(exclude) + return fields + + def get_version_count(self, obj): + return obj.asset_versions.count() + + def get_xls_link(self, obj): + return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) + + def get_xform_link(self, obj): + return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) + + def get_hooks_link(self, obj): + return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) + + def get_embeds(self, obj): + request = self.context.get('request', None) + + def _reverse_lookup_format(fmt): + url = reverse('asset-%s' % fmt, + args=(obj.uid,), + request=request) + return {'format': fmt, + 'url': url, } + base_url = reverse('asset-detail', + args=(obj.uid,), + request=request) + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xform'), + ] + + def get_downloads(self, obj): + def _reverse_lookup_format(fmt): + request = self.context.get('request', None) + obj_url = reverse('asset-detail', args=(obj.uid,), request=request) + # The trailing slash must be removed prior to appending the format + # extension + url = '%s.%s' % (obj_url.rstrip('/'), fmt) + + return {'format': fmt, + 'url': url, } + return [ + _reverse_lookup_format('xls'), + _reverse_lookup_format('xml'), + ] + + def get_koboform_link(self, obj): + return reverse('asset-koboform', args=(obj.uid,), request=self.context + .get('request', None)) + + def get_deployed_version_id(self, obj): + if not obj.has_deployment: + return + if isinstance(obj.deployment.version_id, int): + asset_versions_uids_only = obj.asset_versions.only('uid') + # this can be removed once the 'replace_deployment_ids' + # migration has been run + v_id = obj.deployment.version_id + try: + return asset_versions_uids_only.get( + _reversion_version_id=v_id + ).uid + except AssetVersion.DoesNotExist: + deployed_version = asset_versions_uids_only.filter( + deployed=True + ).first() + if deployed_version: + return deployed_version.uid + else: + return None + else: + return obj.deployment.version_id + + def get_deployment__identifier(self, obj): + if obj.has_deployment: + return obj.deployment.identifier + + def get_deployment__active(self, obj): + return obj.has_deployment and obj.deployment.active + + def get_deployment__links(self, obj): + if obj.has_deployment and obj.deployment.active: + return obj.deployment.get_enketo_survey_links() + else: + return {} + + def get_deployment__data_download_links(self, obj): + if obj.has_deployment: + return obj.deployment.get_data_download_links() + else: + return {} + + def get_deployment__submission_count(self, obj): + if not obj.has_deployment: + return 0 + return obj.deployment.submission_count + + def _content(self, obj): + return json.dumps(obj.content) + + def _table_url(self, obj): + request = self.context.get('request', None) + return reverse('asset-table-view', args=(obj.uid,), request=request) + + +class DeploymentSerializer(serializers.Serializer): + backend = serializers.CharField(required=False) + identifier = serializers.CharField(read_only=True) + active = serializers.BooleanField(required=False) + version_id = serializers.CharField(required=False) + asset = serializers.SerializerMethodField() + + @staticmethod + def _raise_unless_current_version(asset, validated_data): + # Stop if the requester attempts to deploy any version of the asset + # except the current one + if 'version_id' in validated_data and \ + validated_data['version_id'] != str(asset.version_id): + raise NotImplementedError( + 'Only the current version_id can be deployed') + + def get_asset(self, obj): + asset = self.context['asset'] + return AssetSerializer(asset, context=self.context).data + + def create(self, validated_data): + asset = self.context['asset'] + self._raise_unless_current_version(asset, validated_data) + # if no backend is provided, use the installation's default backend + backend_id = validated_data.get('backend', + settings.DEFAULT_DEPLOYMENT_BACKEND) + + # asset.deploy deploys the latest version and updates that versions' + # 'deployed' boolean value + asset.deploy(backend=backend_id, + active=validated_data.get('active', False)) + asset.save(create_version=False, + adjust_content=False) + return asset.deployment + + def update(self, instance, validated_data): + ''' If a `version_id` is provided and differs from the current + deployment's `version_id`, the asset will be redeployed. Otherwise, + only the `active` field will be updated ''' + asset = self.context['asset'] + deployment = asset.deployment + + if 'backend' in validated_data and \ + validated_data['backend'] != deployment.backend: + raise exceptions.ValidationError( + {'backend': 'This field cannot be modified after the initial ' + 'deployment.'}) + + if ('version_id' in validated_data and + validated_data['version_id'] != deployment.version_id): + # Request specified a `version_id` that differs from the current + # deployment's; redeploy + self._raise_unless_current_version(asset, validated_data) + asset.deploy( + backend=deployment.backend, + active=validated_data.get('active', deployment.active) + ) + elif 'active' in validated_data: + # Set the `active` flag without touching the rest of the deployment + deployment.set_active(validated_data['active']) + + asset.save(create_version=False, adjust_content=False) + return deployment + + +class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): + messages = ReadOnlyJSONField(required=False) + + class Meta: + model = ImportTask + fields = ( + 'status', + 'uid', + 'messages', + 'date_created', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + +class ImportTaskListSerializer(ImportTaskSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='importtask-detail' + ) + messages = ReadOnlyJSONField(required=False) + + class Meta(ImportTaskSerializer.Meta): + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + ) + + +class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='exporttask-detail' + ) + messages = ReadOnlyJSONField(required=False) + data = ReadOnlyJSONField() + + class Meta: + model = ExportTask + fields = ( + 'url', + 'status', + 'messages', + 'uid', + 'date_created', + 'last_submission_time', + 'result', + 'data', + ) + extra_kwargs = { + 'status': { + 'read_only': True, + }, + 'uid': { + 'read_only': True, + }, + 'last_submission_time': { + 'read_only': True, + }, + 'result': { + 'read_only': True, + }, + } + + +class AssetListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + # WARNING! If you're changing something here, please update + # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an + # additional database query for each asset in the list. + fields = ('url', + 'date_modified', + 'date_created', + 'owner', + 'summary', + 'owner__username', + 'parent', + 'uid', + 'tag_string', + 'settings', + 'kind', + 'name', + 'asset_type', + 'version_id', + 'has_deployment', + 'deployed_version_id', + 'deployment__identifier', + 'deployment__active', + 'deployment__submission_count', + 'permissions', + 'downloads', + ) + + +class AssetUrlListSerializer(AssetSerializer): + class Meta(AssetSerializer.Meta): + fields = ('url',) + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer + ) + + class Meta: + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) + extra_kwargs = { + 'url' : { + 'lookup_field': 'username', + }, + 'owned_collections': { + 'lookup_field': 'uid', + }, + } + + +class CurrentUserSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + server_time = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + projects_url = serializers.SerializerMethodField() + gravatar = serializers.SerializerMethodField() + languages = serializers.SerializerMethodField() + extra_details = WritableJSONField(source='extra_details.data') + current_password = serializers.CharField(write_only=True, required=False) + new_password = serializers.CharField(write_only=True, required=False) + git_rev = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'username', + 'first_name', + 'last_name', + 'email', + 'server_time', + 'date_joined', + 'projects_url', + 'is_superuser', + 'gravatar', + 'is_staff', + 'last_login', + 'languages', + 'extra_details', + 'current_password', + 'new_password', + 'git_rev', + ) + + def get_server_time(self, obj): + # Currently unused on the front end + return datetime.datetime.now(tz=pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_date_joined(self, obj): + return obj.date_joined.astimezone(pytz.UTC).strftime( + '%Y-%m-%dT%H:%M:%SZ') + + def get_projects_url(self, obj): + return '/'.join((settings.KOBOCAT_URL, obj.username)) + + def get_gravatar(self, obj): + return gravatar_url(obj.email) + + def get_languages(self, obj): + return settings.LANGUAGES + + def get_git_rev(self, obj): + request = self.context.get('request', False) + if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + return settings.GIT_REV + else: + return False + + def to_representation(self, obj): + if obj.is_anonymous(): + return {'message': 'user is not logged in'} + rep = super(CurrentUserSerializer, self).to_representation(obj) + if settings.UPCOMING_DOWNTIME: + # setting is in the format: + # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] + rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME + # TODO: Find a better location for SECTORS and COUNTRIES + # as the functionality develops. (possibly in tags?) + rep['available_sectors'] = SECTORS + rep['available_countries'] = COUNTRIES + rep['all_languages'] = LANGUAGES + if not rep['extra_details']: + rep['extra_details'] = {} + # `require_auth` needs to be read from KC every time + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: + rep['extra_details']['require_auth'] = get_kc_profile_data( + obj.pk).get('require_auth', False) + + return rep + + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source + # fields by default." --DRF + extra_details = validated_data.pop('extra_details', False) + if extra_details: + extra_details_obj, created = ExtraUserDetail.objects.get_or_create( + user=instance) + # `require_auth` needs to be written back to KC + if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ + 'require_auth' in extra_details['data']: + set_kc_require_auth( + instance.pk, extra_details['data']['require_auth']) + extra_details_obj.data.update(extra_details['data']) + extra_details_obj.save() + current_password = validated_data.pop('current_password', False) + new_password = validated_data.pop('new_password', False) + if all((current_password, new_password)): + with transaction.atomic(): + if instance.check_password(current_password): + instance.set_password(new_password) + instance.save() + else: + raise serializers.ValidationError({ + 'current_password': 'Incorrect current password.' + }) + elif any((current_password, new_password)): + raise serializers.ValidationError( + 'current_password and new_password must both be sent ' \ + 'together; one or the other cannot be sent individually.' + ) + return super(CurrentUserSerializer, self).update( + instance, validated_data) + + +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} + ) + email = serializers.EmailField() + class Meta: + model = User + fields = ( + 'username', + 'password', + 'first_name', + 'last_name', + 'email', + #'is_staff', + #'is_superuser', + #'is_active', + ) + extra_kwargs = { + 'password': {'write_only': True}, + 'email': {'required': True} + } + + def create(self, validated_data): + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: + try: + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user + + +class CollectionChildrenSerializer(serializers.Serializer): + def to_representation(self, value): + if isinstance(value, Collection): + serializer = CollectionListSerializer + elif isinstance(value, Asset): + serializer = AssetListSerializer + else: + raise Exception('Unexpected child type {}'.format(type(value))) + return serializer(value, context=self.context).data + + +class CollectionSerializer(serializers.HyperlinkedModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', view_name='collection-detail') + owner = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + read_only=True + ) + parent = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + required=False, + view_name='collection-detail', + queryset=Collection.objects.all() + ) + owner__username = serializers.ReadOnlyField(source='owner.username') + # ancestors are ordered from farthest to nearest + ancestors = AncestorCollectionsSerializer( + many=True, read_only=True, source='get_ancestors_or_none') + children = PaginatedApiField( + serializer_class=CollectionChildrenSerializer, + # "The value `source='*'` has a special meaning, and is used to indicate + # that the entire object should be passed through to the field" + # (http://www.django-rest-framework.org/api-guide/fields/#source). + source='*', + source_processor=lambda source: CollectionChildrenQuerySet( + source + ).optimize_for_list() + ) + permissions = ObjectPermissionSerializer(many=True, read_only=True) + downloads = serializers.SerializerMethodField() + tag_string = serializers.CharField(required=False) + access_type = serializers.SerializerMethodField() + + class Meta: + model = Collection + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'owner__username', + 'downloads', + 'date_created', + 'date_modified', + 'ancestors', + 'children', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + lookup_field = 'uid' + extra_kwargs = { + 'assets': { + 'lookup_field': 'uid', + }, + 'uid': { + 'read_only': True, + }, + } + + def _get_tag_names(self, obj): + return obj.tags.names() + + def get_downloads(self, obj): + request = self.context.get('request', None) + obj_url = reverse( + 'collection-detail', args=(obj.uid,), request=request) + return [ + {'format': 'zip', 'url': '%s?format=zip' % obj_url}, + ] + + def get_access_type(self, obj): + try: + request = self.context['request'] + except KeyError: + return None + if request.user == obj.owner: + return 'owned' + # `obj.permissions.filter(...).exists()` would be cleaner, but it'd + # cost a query. This ugly loop takes advantage of having already called + # `prefetch_related()` + for permission in obj.permissions.all(): + if not permission.deny and permission.user == request.user: + return 'shared' + for subscription in obj.usercollectionsubscription_set.all(): + # `usercollectionsubscription_set__user` is not prefetched + if subscription.user_id == request.user.pk: + return 'subscribed' + if obj.discoverable_when_public: + return 'public' + if request.user.is_superuser: + return 'superuser' + raise Exception(u'{} has unexpected access to {}'.format( + request.user.username, obj.uid)) + + +class SitewideMessageSerializer(serializers.ModelSerializer): + class Meta: + model = SitewideMessage + lookup_field = 'slug' + fields = ('slug', + 'body',) + +class CollectionListSerializer(CollectionSerializer): + children_count = serializers.SerializerMethodField() + assets_count = serializers.SerializerMethodField() + + def get_children_count(self, obj): + return obj.children.count() + + def get_assets_count(self, obj): + return Asset.objects.filter(parent=obj).only('pk').count() + return obj.assets.count() + + class Meta(CollectionSerializer.Meta): + fields = ('name', + 'uid', + 'kind', + 'url', + 'parent', + 'owner', + 'children_count', + 'assets_count', + 'owner__username', + 'date_created', + 'date_modified', + 'permissions', + 'access_type', + 'discoverable_when_public', + 'tag_string',) + + +class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() + password = serializers.CharField(style={'input_type': 'password'}) + token = serializers.CharField(read_only=True) + def to_internal_value(self, data): + field_names = ('username', 'password') + validation_errors = {} + validated_data = {} + for field_name in field_names: + value = data.get(field_name) + if not value: + validation_errors[field_name] = 'This field is required.' + else: + validated_data[field_name] = value + if len(validation_errors): + raise exceptions.ValidationError(validation_errors) + return validated_data + + +class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): + username = serializers.SlugRelatedField( + slug_field='username', source='user', queryset=User.objects.all()) + class Meta: + model = OneTimeAuthenticationKey + fields = ('username', 'key', 'expiry') + + +class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='usercollectionsubscription-detail' + ) + collection = RelativePrefixHyperlinkedRelatedField( + lookup_field='uid', + view_name='collection-detail', + queryset=Collection.objects.none() # will be set in __init__() + ) + uid = serializers.ReadOnlyField() + + def __init__(self, *args, **kwargs): + super(UserCollectionSubscriptionSerializer, self).__init__( + *args, **kwargs) + self.fields['collection'].queryset = get_objects_for_user( + get_anonymous_user(), + PERM_VIEW_COLLECTION, + Collection.objects.filter(discoverable_when_public=True) + ) + + class Meta: + model = UserCollectionSubscription + lookup_field = 'uid' + fields = ('url', 'collection', 'uid') From d29130bd2686ca40f7e43381749fc5ae831bc585 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 09:55:07 -0400 Subject: [PATCH 028/499] WIP - refactored structure of serializers Fields --- kpi/fields/__init__.py | 164 ++- kpi/fields/generic_hyperlinked_related.py | 26 + kpi/fields/kpi_uid.py | 11 + kpi/fields/lazy_default_jsonb.py | 18 + kpi/fields/paginated_api.py | 12 + kpi/fields/read_only.py | 5 + .../relative_prefix_hyperlinked_related.py | 20 + kpi/fields/serializer_method_file.py | 12 + kpi/fields/writeable_json.py | 12 + kpi/serializers/asset_url_list.py | 1263 ----------------- .../fields/generic_hyperlinked_related.py | 1263 ----------------- kpi/serializers/fields/read_only.py | 1263 ----------------- .../relative_prefix_hyperlinked_related.py | 1263 ----------------- kpi/serializers/fields/writeable_json.py | 1263 ----------------- kpi/serializers/object_permission_nested.py | 1263 ----------------- kpi/tests/test_cloning.py | 1 - 16 files changed, 271 insertions(+), 7588 deletions(-) delete mode 100644 kpi/serializers/asset_url_list.py delete mode 100644 kpi/serializers/fields/generic_hyperlinked_related.py delete mode 100644 kpi/serializers/fields/read_only.py delete mode 100644 kpi/serializers/fields/relative_prefix_hyperlinked_related.py delete mode 100644 kpi/serializers/fields/writeable_json.py delete mode 100644 kpi/serializers/object_permission_nested.py diff --git a/kpi/fields/__init__.py b/kpi/fields/__init__.py index bb8b973509..bc07ec65e8 100644 --- a/kpi/fields/__init__.py +++ b/kpi/fields/__init__.py @@ -1,9 +1,155 @@ -# coding: utf-8 -from .generic_hyperlinked_related import GenericHyperlinkedRelatedField -from .kpi_uid import KpiUidField -from .lazy_default_jsonb import LazyDefaultJSONBField -from .paginated_api import PaginatedApiField -from .read_only import ReadOnlyJSONField -from .relative_prefix_hyperlinked_related import RelativePrefixHyperlinkedRelatedField -from .serializer_method_file import SerializerMethodFileField -from .writeable_json import WritableJSONField +import copy +from collections import OrderedDict + +from django.db import models +from django.core.exceptions import FieldError + +from shortuuid import ShortUUID +from rest_framework import serializers +from rest_framework.reverse import reverse +from jsonbfield.fields import JSONField as JSONBField +from rest_framework.pagination import LimitOffsetPagination + +# should be 22 per shortuuid documentation, but keeping at 21 to avoid having +# to migrate dkobo (see SurveyDraft.kpi_asset_uid) +UUID_LENGTH = 21 + + +class KpiUidField(models.CharField): + ''' If empty, automatically populates itself with a UID before saving ''' + def __init__(self, uid_prefix): + self.uid_prefix = uid_prefix + total_length = len(uid_prefix) + UUID_LENGTH + super(KpiUidField, self).__init__(max_length=total_length, unique=True) + + def deconstruct(self): + name, path, args, kwargs = super(KpiUidField, self).deconstruct() + kwargs['uid_prefix'] = self.uid_prefix + del kwargs['max_length'] + del kwargs['unique'] + return name, path, args, kwargs + + def generate_uid(self): + return self.uid_prefix + ShortUUID().random(UUID_LENGTH) + # When UID_LENGTH is 22, that should be changed to: + # return self.uid_prefix + shortuuid.uuid() + + def pre_save(self, model_instance, add): + value = getattr(model_instance, self.attname) + if value == '': + value = self.generate_uid() + setattr(model_instance, self.attname, value) + return value + + +class LazyDefaultJSONBField(JSONBField): + ''' + Allows specifying a default value for a new field without having to rewrite + every row in the corresponding table when migrating the database. + + Whenever the database contains a null: + 1. The field will present the default value instead of None; + 2. The field will overwrite the null with the default value if the + instance it belongs to is saved. + ''' + def __init__(self, *args, **kwargs): + if kwargs.get('null', False): + raise FieldError('Do not manually specify null=True for a ' + 'LazyDefaultJSONBField') + self.lazy_default = kwargs.get('default') + if self.lazy_default is None: + raise FieldError('LazyDefaultJSONBField requires a default that ' + 'is not None') + kwargs['null'] = True + kwargs['default'] = None + super(LazyDefaultJSONBField, self).__init__(*args, **kwargs) + + def _get_lazy_default(self): + if callable(self.lazy_default): + return self.lazy_default() + else: + return self.lazy_default + + def deconstruct(self): + name, path, args, kwargs = super( + LazyDefaultJSONBField, self).deconstruct() + kwargs['default'] = self.lazy_default + del kwargs['null'] + return name, path, args, kwargs + + def from_db_value(self, value, *args, **kwargs): + if value is None: + return self._get_lazy_default() + return value + + def pre_save(self, model_instance, add): + value = getattr(model_instance, self.attname) + if value is None: + setattr(model_instance, self.attname, self._get_lazy_default()) + return value + + +class PaginatedApiField(serializers.ReadOnlyField): + ''' + Serializes a manager or queryset `source` to a paginated representation + ''' + def __init__(self, serializer_class, *args, **kwargs): + r''' + The `source`, whether implied or explicit, must be a manager or + queryset. Alternatively, pass a `source_processor` callable that + transforms `source` into a usable queryset. + + :param serializer_class: The class (not instance) of the desired list + serializer. Required. + :param paginator_class: Optional; defaults to `LimitOffsetPagination`. + :param default_limit: Optional; defaults to `10`. + :param source_processor: Optional; a callable that receives `source` + and must return an usable queryset + ''' + self.serializer_class = serializer_class + self.paginator = kwargs.pop('paginator_class', LimitOffsetPagination)() + self.paginator.default_limit = kwargs.pop('default_limit', 10) + self.source_processor = kwargs.pop('source_processor', None) + return super(PaginatedApiField, self).__init__(*args, **kwargs) + + def to_representation(self, source): + if self.source_processor: + queryset = self.source_processor(source) + else: + queryset = source.all() + # FIXME: The paginator makes `next` and `previous` URLs that don't + # include the name of the field, e.g. paginating the `assets` field in + # `UserSerializer` results in + # `http://host/users/person/?limit=10&offset=10`. This won't allow for + # pagination of more than one field per object + page = self.paginator.paginate_queryset( + queryset=queryset, + request=self.context.get('request', None) + ) + serializer = self.serializer_class( + page, many=True, context=self.context) + return OrderedDict([ + ('count', self.paginator.count), + ('next', self.paginator.get_next_link()), + ('previous', self.paginator.get_previous_link()), + ('results', serializer.data) + ]) + + +class SerializerMethodFileField(serializers.FileField): + ''' + A `FileField` that gets its representation from calling a method on the + parent serializer class, like a `SerializerMethodField`. The method called + will be of the form "get_{field_name}", and should take a single argument, + which is the object being serialized. + ''' + def __init__(self, *args, **kwargs): + self._serializer_method_field = serializers.SerializerMethodField() + super(SerializerMethodFileField, self).__init__(*args, **kwargs) + + def bind(self, *args, **kwargs): + self._serializer_method_field.bind(*args, **kwargs) + super(SerializerMethodFileField, self).bind(*args, **kwargs) + + def to_representation(self, obj): + return self._serializer_method_field.to_representation(obj.instance) diff --git a/kpi/fields/generic_hyperlinked_related.py b/kpi/fields/generic_hyperlinked_related.py index dbc59ab7a0..fa1e8505ff 100644 --- a/kpi/fields/generic_hyperlinked_related.py +++ b/kpi/fields/generic_hyperlinked_related.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from urllib.parse import urlparse @@ -11,6 +12,18 @@ from rest_framework import serializers from kpi.models.object_permission import ObjectPermission +======= +# -*- coding: utf-8 -*- +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import Resolver404 +from django.core.urlresolvers import get_script_prefix +from django.core.urlresolvers import resolve +from django.utils.six.moves.urllib import parse as urlparse +from rest_framework import serializers + +from kpi.models import ObjectPermission +>>>>>>> WIP - refactored structure of serializers Fields class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): @@ -20,13 +33,22 @@ def __init__(self, **kwargs): # situation. We will override them dynamically. kwargs['view_name'] = '*' kwargs['queryset'] = ObjectPermission.objects.none() +<<<<<<< HEAD super().__init__(**kwargs) +======= + return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) +>>>>>>> WIP - refactored structure of serializers Fields def to_representation(self, value): # TODO Figure out why self.view_name is initialized twice in a row? self.view_name = '{}-detail'.format( ContentType.objects.get_for_model(value).model) +<<<<<<< HEAD result = super().to_representation(value) +======= + result = super(GenericHyperlinkedRelatedField, self).to_representation( + value) +>>>>>>> WIP - refactored structure of serializers Fields self.view_name = '*' return result @@ -44,7 +66,11 @@ def to_internal_value(self, data): # TODO: Figure out why DRF only strips absolute URLs, or file bug if True or http_prefix: # If needed convert absolute URLs to relative path +<<<<<<< HEAD data = urlparse(data).path +======= + data = urlparse.urlparse(data).path +>>>>>>> WIP - refactored structure of serializers Fields prefix = get_script_prefix() if data.startswith(prefix): data = '/' + data[len(prefix):] diff --git a/kpi/fields/kpi_uid.py b/kpi/fields/kpi_uid.py index fc40b4acd4..5cef4c7a9b 100644 --- a/kpi/fields/kpi_uid.py +++ b/kpi/fields/kpi_uid.py @@ -1,4 +1,8 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- +>>>>>>> WIP - refactored structure of serializers Fields from django.db import models from shortuuid import ShortUUID @@ -15,10 +19,17 @@ class KpiUidField(models.CharField): def __init__(self, uid_prefix): self.uid_prefix = uid_prefix total_length = len(uid_prefix) + UUID_LENGTH +<<<<<<< HEAD super().__init__(max_length=total_length, unique=True) def deconstruct(self): name, path, args, kwargs = super().deconstruct() +======= + super(KpiUidField, self).__init__(max_length=total_length, unique=True) + + def deconstruct(self): + name, path, args, kwargs = super(KpiUidField, self).deconstruct() +>>>>>>> WIP - refactored structure of serializers Fields kwargs['uid_prefix'] = self.uid_prefix del kwargs['max_length'] del kwargs['unique'] diff --git a/kpi/fields/lazy_default_jsonb.py b/kpi/fields/lazy_default_jsonb.py index eec8803080..d55e5b1f20 100644 --- a/kpi/fields/lazy_default_jsonb.py +++ b/kpi/fields/lazy_default_jsonb.py @@ -1,8 +1,14 @@ +<<<<<<< HEAD # coding: utf-8 from collections import Callable from django.core.exceptions import FieldError from django.contrib.postgres.fields import JSONField as JSONBField +======= +# -*- coding: utf-8 -*- +from django.core.exceptions import FieldError +from jsonbfield.fields import JSONField as JSONBField +>>>>>>> WIP - refactored structure of serializers Fields class LazyDefaultJSONBField(JSONBField): @@ -25,16 +31,28 @@ def __init__(self, *args, **kwargs): 'is not None') kwargs['null'] = True kwargs['default'] = None +<<<<<<< HEAD super().__init__(*args, **kwargs) def _get_lazy_default(self): if isinstance(self.lazy_default, Callable): +======= + super(LazyDefaultJSONBField, self).__init__(*args, **kwargs) + + def _get_lazy_default(self): + if callable(self.lazy_default): +>>>>>>> WIP - refactored structure of serializers Fields return self.lazy_default() else: return self.lazy_default def deconstruct(self): +<<<<<<< HEAD name, path, args, kwargs = super().deconstruct() +======= + name, path, args, kwargs = super( + LazyDefaultJSONBField, self).deconstruct() +>>>>>>> WIP - refactored structure of serializers Fields kwargs['default'] = self.lazy_default del kwargs['null'] return name, path, args, kwargs diff --git a/kpi/fields/paginated_api.py b/kpi/fields/paginated_api.py index 6ba4caf76f..59e4e90e83 100644 --- a/kpi/fields/paginated_api.py +++ b/kpi/fields/paginated_api.py @@ -1,4 +1,8 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- +>>>>>>> WIP - refactored structure of serializers Fields from collections import OrderedDict from rest_framework import serializers @@ -10,7 +14,11 @@ class PaginatedApiField(serializers.ReadOnlyField): Serializes a manager or queryset `source` to a paginated representation """ def __init__(self, serializer_class, *args, **kwargs): +<<<<<<< HEAD """ +======= + r""" +>>>>>>> WIP - refactored structure of serializers Fields The `source`, whether implied or explicit, must be a manager or queryset. Alternatively, pass a `source_processor` callable that transforms `source` into a usable queryset. @@ -26,7 +34,11 @@ def __init__(self, serializer_class, *args, **kwargs): self.paginator = kwargs.pop('paginator_class', LimitOffsetPagination)() self.paginator.default_limit = kwargs.pop('default_limit', 10) self.source_processor = kwargs.pop('source_processor', None) +<<<<<<< HEAD super().__init__(*args, **kwargs) +======= + return super(PaginatedApiField, self).__init__(*args, **kwargs) +>>>>>>> WIP - refactored structure of serializers Fields def to_representation(self, source): if self.source_processor: diff --git a/kpi/fields/read_only.py b/kpi/fields/read_only.py index 158266257d..f8b4d65e7f 100644 --- a/kpi/fields/read_only.py +++ b/kpi/fields/read_only.py @@ -1,4 +1,9 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- + +>>>>>>> WIP - refactored structure of serializers Fields from rest_framework import serializers diff --git a/kpi/fields/relative_prefix_hyperlinked_related.py b/kpi/fields/relative_prefix_hyperlinked_related.py index 85f9501855..1fefb6be0f 100644 --- a/kpi/fields/relative_prefix_hyperlinked_related.py +++ b/kpi/fields/relative_prefix_hyperlinked_related.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from urllib.parse import urlparse @@ -7,6 +8,15 @@ class RelativePrefixHyperlinkedRelatedField(HyperlinkedRelatedField): +======= +# -*- coding: utf-8 -*- +from django.core.urlresolvers import get_script_prefix +from django.utils.six.moves.urllib import parse as urlparse +from rest_framework import serializers + + +class RelativePrefixHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): +>>>>>>> WIP - refactored structure of serializers Fields def to_internal_value(self, data): try: http_prefix = data.startswith(('http:', 'https:')) @@ -17,9 +27,19 @@ def to_internal_value(self, data): # TODO: Figure out why DRF only strips absolute URLs, or file bug if True or http_prefix: # If needed convert absolute URLs to relative path +<<<<<<< HEAD data = urlparse(data).path +======= + data = urlparse.urlparse(data).path +>>>>>>> WIP - refactored structure of serializers Fields prefix = get_script_prefix() if data.startswith(prefix): data = '/' + data[len(prefix):] +<<<<<<< HEAD return super().to_internal_value(data) +======= + return super( + RelativePrefixHyperlinkedRelatedField, self + ).to_internal_value(data) +>>>>>>> WIP - refactored structure of serializers Fields diff --git a/kpi/fields/serializer_method_file.py b/kpi/fields/serializer_method_file.py index 0784b2d3b0..476541377f 100644 --- a/kpi/fields/serializer_method_file.py +++ b/kpi/fields/serializer_method_file.py @@ -1,4 +1,8 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- +>>>>>>> WIP - refactored structure of serializers Fields from rest_framework import serializers @@ -11,11 +15,19 @@ class SerializerMethodFileField(serializers.FileField): """ def __init__(self, *args, **kwargs): self._serializer_method_field = serializers.SerializerMethodField() +<<<<<<< HEAD super().__init__(*args, **kwargs) def bind(self, *args, **kwargs): self._serializer_method_field.bind(*args, **kwargs) super().bind(*args, **kwargs) +======= + super(SerializerMethodFileField, self).__init__(*args, **kwargs) + + def bind(self, *args, **kwargs): + self._serializer_method_field.bind(*args, **kwargs) + super(SerializerMethodFileField, self).bind(*args, **kwargs) +>>>>>>> WIP - refactored structure of serializers Fields def to_representation(self, obj): return self._serializer_method_field.to_representation(obj.instance) diff --git a/kpi/fields/writeable_json.py b/kpi/fields/writeable_json.py index abda25765d..dfe6b88da5 100644 --- a/kpi/fields/writeable_json.py +++ b/kpi/fields/writeable_json.py @@ -1,4 +1,8 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- +>>>>>>> WIP - refactored structure of serializers Fields import json from rest_framework import serializers @@ -11,7 +15,11 @@ class WritableJSONField(serializers.Field): def __init__(self, **kwargs): self.allow_blank = kwargs.pop('allow_blank', False) +<<<<<<< HEAD super().__init__(**kwargs) +======= + super(WritableJSONField, self).__init__(**kwargs) +>>>>>>> WIP - refactored structure of serializers Fields def to_internal_value(self, data): if (not data) and (not self.required): @@ -21,7 +29,11 @@ def to_internal_value(self, data): return json.loads(data) except Exception as e: raise serializers.ValidationError( +<<<<<<< HEAD 'Unable to parse JSON: {}'.format(e)) +======= + u'Unable to parse JSON: {}'.format(e)) +>>>>>>> WIP - refactored structure of serializers Fields def to_representation(self, value): return value diff --git a/kpi/serializers/asset_url_list.py b/kpi/serializers/asset_url_list.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/asset_url_list.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/generic_hyperlinked_related.py b/kpi/serializers/fields/generic_hyperlinked_related.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/fields/generic_hyperlinked_related.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/read_only.py b/kpi/serializers/fields/read_only.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/fields/read_only.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/relative_prefix_hyperlinked_related.py b/kpi/serializers/fields/relative_prefix_hyperlinked_related.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/fields/relative_prefix_hyperlinked_related.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/fields/writeable_json.py b/kpi/serializers/fields/writeable_json.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/fields/writeable_json.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/object_permission_nested.py b/kpi/serializers/object_permission_nested.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/object_permission_nested.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/tests/test_cloning.py b/kpi/tests/test_cloning.py index 6d6887b179..1fe3dd6523 100644 --- a/kpi/tests/test_cloning.py +++ b/kpi/tests/test_cloning.py @@ -1,4 +1,3 @@ -# coding: utf-8 """ Created on Jun 15, 2015 From 4afada418c146fd7e0839b0eb9a16d937c6deeb0 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 11:19:22 -0400 Subject: [PATCH 029/499] Views and Serializers splitted --- kpi/fields/__init__.py | 166 +-- kpi/fields/generic_hyperlinked_related.py | 104 -- kpi/fields/kpi_uid.py | 5 + kpi/fields/lazy_default_jsonb.py | 18 - kpi/fields/paginated_api.py | 21 +- kpi/fields/read_only.py | 5 - .../relative_prefix_hyperlinked_related.py | 20 - kpi/fields/serializer_method_file.py | 13 +- kpi/fields/writeable_json.py | 23 +- kpi/models/asset.py | 5 + .../asset_user_restricted_permission.py | 1 + kpi/serializers/__init__.py | 1260 +--------------- kpi/serializers/ancestor_collections.py | 1254 +--------------- kpi/serializers/asset.py | 997 +------------ kpi/serializers/asset_file.py | 1228 +--------------- kpi/serializers/asset_snapshot.py | 1169 +-------------- kpi/serializers/asset_version.py | 1227 +--------------- .../authorized_application_user.py | 1244 +--------------- kpi/serializers/collection.py | 1132 +-------------- kpi/serializers/collection_children.py | 1263 ----------------- kpi/serializers/deployment.py | 1198 +--------------- kpi/serializers/export_task.py | 1228 +--------------- kpi/serializers/import_task.py | 1231 +--------------- kpi/serializers/object_permission.py | 1195 +--------------- .../one_time_authentication_key.py | 1257 +--------------- kpi/serializers/sitewide_message.py | 1255 +--------------- kpi/serializers/tag.py | 1212 +--------------- kpi/serializers/user.py | 1168 +-------------- .../user_collection_subscription.py | 1244 +--------------- 29 files changed, 180 insertions(+), 21963 deletions(-) delete mode 100644 kpi/fields/generic_hyperlinked_related.py delete mode 100644 kpi/serializers/collection_children.py diff --git a/kpi/fields/__init__.py b/kpi/fields/__init__.py index bc07ec65e8..5153203e09 100644 --- a/kpi/fields/__init__.py +++ b/kpi/fields/__init__.py @@ -1,155 +1,11 @@ -import copy -from collections import OrderedDict - -from django.db import models -from django.core.exceptions import FieldError - -from shortuuid import ShortUUID -from rest_framework import serializers -from rest_framework.reverse import reverse -from jsonbfield.fields import JSONField as JSONBField -from rest_framework.pagination import LimitOffsetPagination - -# should be 22 per shortuuid documentation, but keeping at 21 to avoid having -# to migrate dkobo (see SurveyDraft.kpi_asset_uid) -UUID_LENGTH = 21 - - -class KpiUidField(models.CharField): - ''' If empty, automatically populates itself with a UID before saving ''' - def __init__(self, uid_prefix): - self.uid_prefix = uid_prefix - total_length = len(uid_prefix) + UUID_LENGTH - super(KpiUidField, self).__init__(max_length=total_length, unique=True) - - def deconstruct(self): - name, path, args, kwargs = super(KpiUidField, self).deconstruct() - kwargs['uid_prefix'] = self.uid_prefix - del kwargs['max_length'] - del kwargs['unique'] - return name, path, args, kwargs - - def generate_uid(self): - return self.uid_prefix + ShortUUID().random(UUID_LENGTH) - # When UID_LENGTH is 22, that should be changed to: - # return self.uid_prefix + shortuuid.uuid() - - def pre_save(self, model_instance, add): - value = getattr(model_instance, self.attname) - if value == '': - value = self.generate_uid() - setattr(model_instance, self.attname, value) - return value - - -class LazyDefaultJSONBField(JSONBField): - ''' - Allows specifying a default value for a new field without having to rewrite - every row in the corresponding table when migrating the database. - - Whenever the database contains a null: - 1. The field will present the default value instead of None; - 2. The field will overwrite the null with the default value if the - instance it belongs to is saved. - ''' - def __init__(self, *args, **kwargs): - if kwargs.get('null', False): - raise FieldError('Do not manually specify null=True for a ' - 'LazyDefaultJSONBField') - self.lazy_default = kwargs.get('default') - if self.lazy_default is None: - raise FieldError('LazyDefaultJSONBField requires a default that ' - 'is not None') - kwargs['null'] = True - kwargs['default'] = None - super(LazyDefaultJSONBField, self).__init__(*args, **kwargs) - - def _get_lazy_default(self): - if callable(self.lazy_default): - return self.lazy_default() - else: - return self.lazy_default - - def deconstruct(self): - name, path, args, kwargs = super( - LazyDefaultJSONBField, self).deconstruct() - kwargs['default'] = self.lazy_default - del kwargs['null'] - return name, path, args, kwargs - - def from_db_value(self, value, *args, **kwargs): - if value is None: - return self._get_lazy_default() - return value - - def pre_save(self, model_instance, add): - value = getattr(model_instance, self.attname) - if value is None: - setattr(model_instance, self.attname, self._get_lazy_default()) - return value - - -class PaginatedApiField(serializers.ReadOnlyField): - ''' - Serializes a manager or queryset `source` to a paginated representation - ''' - def __init__(self, serializer_class, *args, **kwargs): - r''' - The `source`, whether implied or explicit, must be a manager or - queryset. Alternatively, pass a `source_processor` callable that - transforms `source` into a usable queryset. - - :param serializer_class: The class (not instance) of the desired list - serializer. Required. - :param paginator_class: Optional; defaults to `LimitOffsetPagination`. - :param default_limit: Optional; defaults to `10`. - :param source_processor: Optional; a callable that receives `source` - and must return an usable queryset - ''' - self.serializer_class = serializer_class - self.paginator = kwargs.pop('paginator_class', LimitOffsetPagination)() - self.paginator.default_limit = kwargs.pop('default_limit', 10) - self.source_processor = kwargs.pop('source_processor', None) - return super(PaginatedApiField, self).__init__(*args, **kwargs) - - def to_representation(self, source): - if self.source_processor: - queryset = self.source_processor(source) - else: - queryset = source.all() - # FIXME: The paginator makes `next` and `previous` URLs that don't - # include the name of the field, e.g. paginating the `assets` field in - # `UserSerializer` results in - # `http://host/users/person/?limit=10&offset=10`. This won't allow for - # pagination of more than one field per object - page = self.paginator.paginate_queryset( - queryset=queryset, - request=self.context.get('request', None) - ) - serializer = self.serializer_class( - page, many=True, context=self.context) - return OrderedDict([ - ('count', self.paginator.count), - ('next', self.paginator.get_next_link()), - ('previous', self.paginator.get_previous_link()), - ('results', serializer.data) - ]) - - -class SerializerMethodFileField(serializers.FileField): - ''' - A `FileField` that gets its representation from calling a method on the - parent serializer class, like a `SerializerMethodField`. The method called - will be of the form "get_{field_name}", and should take a single argument, - which is the object being serialized. - ''' - def __init__(self, *args, **kwargs): - self._serializer_method_field = serializers.SerializerMethodField() - super(SerializerMethodFileField, self).__init__(*args, **kwargs) - - def bind(self, *args, **kwargs): - self._serializer_method_field.bind(*args, **kwargs) - super(SerializerMethodFileField, self).bind(*args, **kwargs) - - def to_representation(self, obj): - return self._serializer_method_field.to_representation(obj.instance) +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from .generic_hyperlinked_related import GenericHyperlinkedRelatedField +from .kpi_uid import KpiUidField +from .lazy_default_jsonb import LazyDefaultJSONBField +from .paginated_api import PaginatedApiField +from .read_only import ReadOnlyJSONField +from .relative_prefix_hyperlinked_related import RelativePrefixHyperlinkedRelatedField +from .serializer_method_file import SerializerMethodFileField +from .writeable_json import WritableJSONField diff --git a/kpi/fields/generic_hyperlinked_related.py b/kpi/fields/generic_hyperlinked_related.py deleted file mode 100644 index fa1e8505ff..0000000000 --- a/kpi/fields/generic_hyperlinked_related.py +++ /dev/null @@ -1,104 +0,0 @@ -<<<<<<< HEAD -# coding: utf-8 -from urllib.parse import urlparse - -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.urls import ( - Resolver404, - get_script_prefix, - resolve, -) -from rest_framework import serializers - -from kpi.models.object_permission import ObjectPermission -======= -# -*- coding: utf-8 -*- -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import Resolver404 -from django.core.urlresolvers import get_script_prefix -from django.core.urlresolvers import resolve -from django.utils.six.moves.urllib import parse as urlparse -from rest_framework import serializers - -from kpi.models import ObjectPermission ->>>>>>> WIP - refactored structure of serializers Fields - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() -<<<<<<< HEAD - super().__init__(**kwargs) -======= - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) ->>>>>>> WIP - refactored structure of serializers Fields - - def to_representation(self, value): - # TODO Figure out why self.view_name is initialized twice in a row? - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) -<<<<<<< HEAD - result = super().to_representation(value) -======= - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) ->>>>>>> WIP - refactored structure of serializers Fields - self.view_name = '*' - return result - - def to_internal_value(self, data): - """ The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. """ - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path -<<<<<<< HEAD - data = urlparse(data).path -======= - data = urlparse.urlparse(data).path ->>>>>>> WIP - refactored structure of serializers Fields - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - # ## Begin modifications ### - # We're a generic relation; we don't discriminate - """ - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - """ - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - # ## End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') diff --git a/kpi/fields/kpi_uid.py b/kpi/fields/kpi_uid.py index 5cef4c7a9b..438baf69a5 100644 --- a/kpi/fields/kpi_uid.py +++ b/kpi/fields/kpi_uid.py @@ -2,7 +2,12 @@ # coding: utf-8 ======= # -*- coding: utf-8 -*- +<<<<<<< HEAD >>>>>>> WIP - refactored structure of serializers Fields +======= +from __future__ import absolute_import + +>>>>>>> Views and Serializers splitted from django.db import models from shortuuid import ShortUUID diff --git a/kpi/fields/lazy_default_jsonb.py b/kpi/fields/lazy_default_jsonb.py index d55e5b1f20..eec8803080 100644 --- a/kpi/fields/lazy_default_jsonb.py +++ b/kpi/fields/lazy_default_jsonb.py @@ -1,14 +1,8 @@ -<<<<<<< HEAD # coding: utf-8 from collections import Callable from django.core.exceptions import FieldError from django.contrib.postgres.fields import JSONField as JSONBField -======= -# -*- coding: utf-8 -*- -from django.core.exceptions import FieldError -from jsonbfield.fields import JSONField as JSONBField ->>>>>>> WIP - refactored structure of serializers Fields class LazyDefaultJSONBField(JSONBField): @@ -31,28 +25,16 @@ def __init__(self, *args, **kwargs): 'is not None') kwargs['null'] = True kwargs['default'] = None -<<<<<<< HEAD super().__init__(*args, **kwargs) def _get_lazy_default(self): if isinstance(self.lazy_default, Callable): -======= - super(LazyDefaultJSONBField, self).__init__(*args, **kwargs) - - def _get_lazy_default(self): - if callable(self.lazy_default): ->>>>>>> WIP - refactored structure of serializers Fields return self.lazy_default() else: return self.lazy_default def deconstruct(self): -<<<<<<< HEAD name, path, args, kwargs = super().deconstruct() -======= - name, path, args, kwargs = super( - LazyDefaultJSONBField, self).deconstruct() ->>>>>>> WIP - refactored structure of serializers Fields kwargs['default'] = self.lazy_default del kwargs['null'] return name, path, args, kwargs diff --git a/kpi/fields/paginated_api.py b/kpi/fields/paginated_api.py index 59e4e90e83..89059da055 100644 --- a/kpi/fields/paginated_api.py +++ b/kpi/fields/paginated_api.py @@ -1,10 +1,7 @@ -<<<<<<< HEAD # coding: utf-8 -======= -# -*- coding: utf-8 -*- ->>>>>>> WIP - refactored structure of serializers Fields from collections import OrderedDict +from django.utils.module_loading import import_string from rest_framework import serializers from rest_framework.pagination import LimitOffsetPagination @@ -14,15 +11,10 @@ class PaginatedApiField(serializers.ReadOnlyField): Serializes a manager or queryset `source` to a paginated representation """ def __init__(self, serializer_class, *args, **kwargs): -<<<<<<< HEAD """ -======= - r""" ->>>>>>> WIP - refactored structure of serializers Fields The `source`, whether implied or explicit, must be a manager or queryset. Alternatively, pass a `source_processor` callable that transforms `source` into a usable queryset. - :param serializer_class: The class (not instance) of the desired list serializer. Required. :param paginator_class: Optional; defaults to `LimitOffsetPagination`. @@ -34,11 +26,7 @@ def __init__(self, serializer_class, *args, **kwargs): self.paginator = kwargs.pop('paginator_class', LimitOffsetPagination)() self.paginator.default_limit = kwargs.pop('default_limit', 10) self.source_processor = kwargs.pop('source_processor', None) -<<<<<<< HEAD super().__init__(*args, **kwargs) -======= - return super(PaginatedApiField, self).__init__(*args, **kwargs) ->>>>>>> WIP - refactored structure of serializers Fields def to_representation(self, source): if self.source_processor: @@ -54,8 +42,11 @@ def to_representation(self, source): queryset=queryset, request=self.context.get('request', None) ) - serializer = self.serializer_class( - page, many=True, context=self.context) + if isinstance(self.serializer_class, str): + serializer_class = import_string(self.serializer_class) + else: + serializer_class = self.serializer_class + serializer = serializer_class(page, many=True, context=self.context) return OrderedDict([ ('count', self.paginator.count), ('next', self.paginator.get_next_link()), diff --git a/kpi/fields/read_only.py b/kpi/fields/read_only.py index f8b4d65e7f..158266257d 100644 --- a/kpi/fields/read_only.py +++ b/kpi/fields/read_only.py @@ -1,9 +1,4 @@ -<<<<<<< HEAD # coding: utf-8 -======= -# -*- coding: utf-8 -*- - ->>>>>>> WIP - refactored structure of serializers Fields from rest_framework import serializers diff --git a/kpi/fields/relative_prefix_hyperlinked_related.py b/kpi/fields/relative_prefix_hyperlinked_related.py index 1fefb6be0f..85f9501855 100644 --- a/kpi/fields/relative_prefix_hyperlinked_related.py +++ b/kpi/fields/relative_prefix_hyperlinked_related.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD # coding: utf-8 from urllib.parse import urlparse @@ -8,15 +7,6 @@ class RelativePrefixHyperlinkedRelatedField(HyperlinkedRelatedField): -======= -# -*- coding: utf-8 -*- -from django.core.urlresolvers import get_script_prefix -from django.utils.six.moves.urllib import parse as urlparse -from rest_framework import serializers - - -class RelativePrefixHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): ->>>>>>> WIP - refactored structure of serializers Fields def to_internal_value(self, data): try: http_prefix = data.startswith(('http:', 'https:')) @@ -27,19 +17,9 @@ def to_internal_value(self, data): # TODO: Figure out why DRF only strips absolute URLs, or file bug if True or http_prefix: # If needed convert absolute URLs to relative path -<<<<<<< HEAD data = urlparse(data).path -======= - data = urlparse.urlparse(data).path ->>>>>>> WIP - refactored structure of serializers Fields prefix = get_script_prefix() if data.startswith(prefix): data = '/' + data[len(prefix):] -<<<<<<< HEAD return super().to_internal_value(data) -======= - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) ->>>>>>> WIP - refactored structure of serializers Fields diff --git a/kpi/fields/serializer_method_file.py b/kpi/fields/serializer_method_file.py index 476541377f..b0288b419f 100644 --- a/kpi/fields/serializer_method_file.py +++ b/kpi/fields/serializer_method_file.py @@ -1,8 +1,5 @@ -<<<<<<< HEAD + # coding: utf-8 -======= -# -*- coding: utf-8 -*- ->>>>>>> WIP - refactored structure of serializers Fields from rest_framework import serializers @@ -15,19 +12,11 @@ class SerializerMethodFileField(serializers.FileField): """ def __init__(self, *args, **kwargs): self._serializer_method_field = serializers.SerializerMethodField() -<<<<<<< HEAD super().__init__(*args, **kwargs) def bind(self, *args, **kwargs): self._serializer_method_field.bind(*args, **kwargs) super().bind(*args, **kwargs) -======= - super(SerializerMethodFileField, self).__init__(*args, **kwargs) - - def bind(self, *args, **kwargs): - self._serializer_method_field.bind(*args, **kwargs) - super(SerializerMethodFileField, self).bind(*args, **kwargs) ->>>>>>> WIP - refactored structure of serializers Fields def to_representation(self, obj): return self._serializer_method_field.to_representation(obj.instance) diff --git a/kpi/fields/writeable_json.py b/kpi/fields/writeable_json.py index dfe6b88da5..96e17f73ed 100644 --- a/kpi/fields/writeable_json.py +++ b/kpi/fields/writeable_json.py @@ -1,8 +1,5 @@ -<<<<<<< HEAD + # coding: utf-8 -======= -# -*- coding: utf-8 -*- ->>>>>>> WIP - refactored structure of serializers Fields import json from rest_framework import serializers @@ -15,25 +12,23 @@ class WritableJSONField(serializers.Field): def __init__(self, **kwargs): self.allow_blank = kwargs.pop('allow_blank', False) -<<<<<<< HEAD super().__init__(**kwargs) -======= - super(WritableJSONField, self).__init__(**kwargs) ->>>>>>> WIP - refactored structure of serializers Fields def to_internal_value(self, data): + # If data is sent to serializer as `dict`, not `str` + # Return as is (e.g. `data` is equals `{}`) + if isinstance(data, dict): + return data + if (not data) and (not self.required): return None else: try: return json.loads(data) except Exception as e: - raise serializers.ValidationError( -<<<<<<< HEAD - 'Unable to parse JSON: {}'.format(e)) -======= - u'Unable to parse JSON: {}'.format(e)) ->>>>>>> WIP - refactored structure of serializers Fields + raise serializers.ValidationError({ + 'writable_jsonfield': 'Unable to parse JSON: {}'.format(e) + }) def to_representation(self, value): return value diff --git a/kpi/models/asset.py b/kpi/models/asset.py index ffc02b8352..df8b796d12 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -24,6 +24,11 @@ from formpack.utils.flatten_content import flatten_content from formpack.utils.json_hash import json_hash from formpack.utils.spreadsheet_content import flatten_to_spreadsheet_content +from jsonbfield.fields import JSONField as JSONBField +from jsonfield import JSONField +from taggit.managers import TaggableManager, _TaggableManager +from taggit.utils import require_instance_manager + from kobo.apps.reports.constants import (SPECIFIC_REPORTS_KEY, DEFAULT_REPORTS_KEY) from kpi.constants import ( diff --git a/kpi/models/asset_user_restricted_permission.py b/kpi/models/asset_user_restricted_permission.py index 251fa5868e..fb92a01981 100644 --- a/kpi/models/asset_user_restricted_permission.py +++ b/kpi/models/asset_user_restricted_permission.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import from django.utils import timezone from django.db import models diff --git a/kpi/serializers/__init__.py b/kpi/serializers/__init__.py index 9d3075d396..df9837de56 100644 --- a/kpi/serializers/__init__.py +++ b/kpi/serializers/__init__.py @@ -1,6 +1,5 @@ -<<<<<<< HEAD + # coding: utf-8 -from .v1.ancestor_collections import AncestorCollectionsSerializer from .v1.asset import AssetListSerializer from .v1.asset import AssetSerializer from .v1.asset import AssetUrlListSerializer @@ -9,9 +8,6 @@ from .v1.asset_version import AssetVersionListSerializer from .v1.asset_version import AssetVersionSerializer from .v1.authorized_application_user import AuthorizedApplicationUserSerializer -from .v1.collection import CollectionListSerializer -from .v1.collection import CollectionSerializer -from .v1.collection import CollectionChildrenSerializer from .v1.deployment import DeploymentSerializer from .v1.export_task import ExportTaskSerializer from .v1.import_task import ImportTaskListSerializer @@ -25,1256 +21,4 @@ from .create_user import CreateUserSerializer from .current_user import CurrentUserSerializer from .v1.user import UserSerializer -from .v1.user_collection_subscription import UserCollectionSubscriptionSerializer -======= -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous: - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous: - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous: - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - rep['extra_details']['can_publicize_collection'] = obj.has_perm( - 'kpi.publicize_collection' - ) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') ->>>>>>> WIP - split serializers +from .v1.user_asset_subscription import UserAssetSubscriptionSerializer \ No newline at end of file diff --git a/kpi/serializers/ancestor_collections.py b/kpi/serializers/ancestor_collections.py index 699ab0a071..e9478ebd43 100644 --- a/kpi/serializers/ancestor_collections.py +++ b/kpi/serializers/ancestor_collections.py @@ -1,300 +1,9 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) +from kpi.models import Collection class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): @@ -304,960 +13,3 @@ class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Collection fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset.py b/kpi/serializers/asset.py index 699ab0a071..b56ae7c08c 100644 --- a/kpi/serializers/asset.py +++ b/kpi/serializers/asset.py @@ -1,483 +1,20 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() +from __future__ import absolute_import - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) +import json - def get_content(self, obj): - return obj.version_content +from rest_framework import serializers +from rest_framework.reverse import reverse - def get_version_id(self, obj): - return obj.uid +from kpi.fields import RelativePrefixHyperlinkedRelatedField, WritableJSONField, \ + PaginatedApiField +from kpi.models import Asset, AssetVersion, Collection +from kpi.models.asset import ASSET_TYPES +from kpi.models.object_permission import get_anonymous_user - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) +from .ancestor_collections import AncestorCollectionsSerializer +from .asset_version import AssetVersionListSerializer +from .object_permission import ObjectPermissionNestedSerializer class AssetSerializer(serializers.HyperlinkedModelSerializer): @@ -719,141 +256,6 @@ def _table_url(self, obj): return reverse('asset-table-view', args=(obj.uid,), request=request) -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - class AssetListSerializer(AssetSerializer): class Meta(AssetSerializer.Meta): # WARNING! If you're changing something here, please update @@ -886,378 +288,3 @@ class Meta(AssetSerializer.Meta): class AssetUrlListSerializer(AssetSerializer): class Meta(AssetSerializer.Meta): fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_file.py b/kpi/serializers/asset_file.py index 699ab0a071..75bfd5f823 100644 --- a/kpi/serializers/asset_file.py +++ b/kpi/serializers/asset_file.py @@ -1,408 +1,12 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers +from rest_framework.reverse import reverse -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) +from kpi.fields import RelativePrefixHyperlinkedRelatedField, \ + SerializerMethodFileField, WritableJSONField +from kpi.models import AssetFile class AssetFileSerializer(serializers.ModelSerializer): @@ -441,823 +45,3 @@ class Meta: 'content', 'metadata', ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_snapshot.py b/kpi/serializers/asset_snapshot.py index 699ab0a071..b3ad476411 100644 --- a/kpi/serializers/asset_snapshot.py +++ b/kpi/serializers/asset_snapshot.py @@ -1,309 +1,14 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import exceptions +from rest_framework import serializers +from rest_framework.reverse import reverse -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') +from kpi.constants import PERM_VIEW_ASSET +from kpi.fields import RelativePrefixHyperlinkedRelatedField, WritableJSONField +from kpi.models import Asset +from kpi.models import AssetSnapshot class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): @@ -403,861 +108,3 @@ class Meta: 'details', 'source', ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/asset_version.py b/kpi/serializers/asset_version.py index 699ab0a071..5dba04947e 100644 --- a/kpi/serializers/asset_version.py +++ b/kpi/serializers/asset_version.py @@ -1,446 +1,10 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers +from rest_framework.reverse import reverse -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) +from kpi.models import AssetVersion class AssetVersionListSerializer(serializers.Serializer): @@ -478,786 +42,3 @@ class Meta: 'content_hash', 'content', ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/authorized_application_user.py b/kpi/serializers/authorized_application_user.py index 699ab0a071..0c6987299a 100644 --- a/kpi/serializers/authorized_application_user.py +++ b/kpi/serializers/authorized_application_user.py @@ -1,1218 +1,15 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): + username = serializers.CharField() password = serializers.CharField(style={'input_type': 'password'}) token = serializers.CharField(read_only=True) + def to_internal_value(self, data): field_names = ('username', 'password') validation_errors = {} @@ -1226,38 +23,3 @@ def to_internal_value(self, data): if len(validation_errors): raise exceptions.ValidationError(validation_errors) return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/collection.py b/kpi/serializers/collection.py index 699ab0a071..17bed2ae71 100644 --- a/kpi/serializers/collection.py +++ b/kpi/serializers/collection.py @@ -1,1068 +1,17 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers +from rest_framework.reverse import reverse -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url +from kpi.fields import RelativePrefixHyperlinkedRelatedField, PaginatedApiField +from kpi.models import Asset +from kpi.models import Collection +from kpi.models import CollectionChildrenQuerySet -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user +from .asset import AssetListSerializer +from .ancestor_collections import AncestorCollectionsSerializer +from .object_permission import ObjectPermissionSerializer class CollectionChildrenSerializer(serializers.Serializer): @@ -1173,13 +122,6 @@ def get_access_type(self, obj): request.user.username, obj.uid)) -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - class CollectionListSerializer(CollectionSerializer): children_count = serializers.SerializerMethodField() assets_count = serializers.SerializerMethodField() @@ -1207,57 +149,3 @@ class Meta(CollectionSerializer.Meta): 'access_type', 'discoverable_when_public', 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/collection_children.py b/kpi/serializers/collection_children.py deleted file mode 100644 index 699ab0a071..0000000000 --- a/kpi/serializers/collection_children.py +++ /dev/null @@ -1,1263 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/deployment.py b/kpi/serializers/deployment.py index 699ab0a071..d89b1eb569 100644 --- a/kpi/serializers/deployment.py +++ b/kpi/serializers/deployment.py @@ -1,722 +1,11 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers +from rest_framework import exceptions -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) +from .asset import AssetSerializer class DeploymentSerializer(serializers.Serializer): @@ -782,482 +71,3 @@ def update(self, instance, validated_data): asset.save(create_version=False, adjust_content=False) return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/export_task.py b/kpi/serializers/export_task.py index 699ab0a071..a70c666041 100644 --- a/kpi/serializers/export_task.py +++ b/kpi/serializers/export_task.py @@ -1,821 +1,10 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) +from kpi.fields import ReadOnlyJSONField +from kpi.models import ExportTask class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): @@ -852,412 +41,3 @@ class Meta: 'read_only': True, }, } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/import_task.py b/kpi/serializers/import_task.py index 699ab0a071..9f494be75f 100644 --- a/kpi/serializers/import_task.py +++ b/kpi/serializers/import_task.py @@ -1,787 +1,9 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment +from rest_framework import serializers +from kpi.fields import ReadOnlyJSONField +from kpi.models import ImportTask class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): @@ -801,6 +23,7 @@ class Meta: }, } + class ImportTaskListSerializer(ImportTaskSerializer): url = serializers.HyperlinkedIdentityField( lookup_field='uid', @@ -817,447 +40,3 @@ class Meta(ImportTaskSerializer.Meta): 'date_created', ) - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/object_permission.py b/kpi/serializers/object_permission.py index 699ab0a071..5df635f8b5 100644 --- a/kpi/serializers/object_permission.py +++ b/kpi/serializers/object_permission.py @@ -1,228 +1,16 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) +from kpi.constants import PERM_FROM_KC_ONLY +from kpi.fields import GenericHyperlinkedRelatedField, \ + RelativePrefixHyperlinkedRelatedField +from kpi.models import ObjectPermission class ObjectPermissionSerializer(serializers.ModelSerializer): @@ -291,973 +79,6 @@ class Meta(ObjectPermissionSerializer.Meta): 'url', 'user', 'permission', - #'content_object', 'deny', 'inherited', ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/one_time_authentication_key.py b/kpi/serializers/one_time_authentication_key.py index 699ab0a071..9cf511f7ee 100644 --- a/kpi/serializers/one_time_authentication_key.py +++ b/kpi/serializers/one_time_authentication_key.py @@ -1,1263 +1,16 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from django.contrib.auth.models import User +from rest_framework import serializers -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data +from kpi.models import OneTimeAuthenticationKey class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): username = serializers.SlugRelatedField( slug_field='username', source='user', queryset=User.objects.all()) + class Meta: model = OneTimeAuthenticationKey fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/sitewide_message.py b/kpi/serializers/sitewide_message.py index 699ab0a071..e92586ec01 100644 --- a/kpi/serializers/sitewide_message.py +++ b/kpi/serializers/sitewide_message.py @@ -1,1176 +1,9 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) +from hub.models import SitewideMessage class SitewideMessageSerializer(serializers.ModelSerializer): @@ -1179,85 +12,3 @@ class Meta: lookup_field = 'slug' fields = ('slug', 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/tag.py b/kpi/serializers/tag.py index 699ab0a071..8d8303a563 100644 --- a/kpi/serializers/tag.py +++ b/kpi/serializers/tag.py @@ -1,176 +1,12 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict +from __future__ import absolute_import -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse +from rest_framework import serializers +from rest_framework.reverse import reverse from taggit.models import Tag -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) +from kpi.models import Asset, Collection, TagUid +from kpi.models.object_permission import get_anonymous_user class TagSerializer(serializers.ModelSerializer): @@ -223,1041 +59,3 @@ class TagListSerializer(TagSerializer): class Meta: model = Tag fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/user.py b/kpi/serializers/user.py index 699ab0a071..859738df69 100644 --- a/kpi/serializers/user.py +++ b/kpi/serializers/user.py @@ -1,913 +1,64 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import + import datetime -import json import pytz -from collections import OrderedDict -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 +from django.contrib.auth.models import User from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag +from rest_framework import serializers from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - +from hub.models import ExtraUserDetail from kpi.deployment_backends.kc_access.utils import get_kc_profile_data from kpi.deployment_backends.kc_access.utils import set_kc_require_auth +from kpi.fields import PaginatedApiField, WritableJSONField +from kpi.forms import USERNAME_REGEX +from kpi.forms import USERNAME_MAX_LENGTH +from kpi.forms import USERNAME_INVALID_MESSAGE +from kpi.utils.gravatar_url import gravatar_url +from .asset import AssetUrlListSerializer -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box +class CreateUserSerializer(serializers.ModelSerializer): + username = serializers.RegexField( + regex=USERNAME_REGEX, + max_length=USERNAME_MAX_LENGTH, + error_messages={'invalid': USERNAME_INVALID_MESSAGE} ) - inherited = serializers.ReadOnlyField() + email = serializers.EmailField() class Meta: - model = ObjectPermission + model = User fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', + 'username', + 'password', + 'first_name', + 'last_name', + 'email', ) extra_kwargs = { - 'uid': { - 'read_only': True, - }, + 'password': {'write_only': True}, + 'email': {'required': True} } def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id + user = User() + user.set_password(validated_data['password']) + non_password_fields = list(self.Meta.fields) + try: + non_password_fields.remove('password') + except ValueError: + pass + for field in non_password_fields: try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } + setattr(user, field, validated_data[field]) + except KeyError: + pass + user.save() + return user class CurrentUserSerializer(serializers.ModelSerializer): @@ -1024,240 +175,23 @@ def update(self, instance, validated_data): instance, validated_data) -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() +class UserSerializer(serializers.HyperlinkedModelSerializer): + assets = PaginatedApiField( + serializer_class=AssetUrlListSerializer ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' + model = User + fields = ('url', + 'username', + 'assets', + 'owned_collections', + ) extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', + 'url' : { + 'lookup_field': 'username', }, - 'uid': { - 'read_only': True, + 'owned_collections': { + 'lookup_field': 'uid', }, } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/serializers/user_collection_subscription.py b/kpi/serializers/user_collection_subscription.py index 699ab0a071..3a6455858a 100644 --- a/kpi/serializers/user_collection_subscription.py +++ b/kpi/serializers/user_collection_subscription.py @@ -1,1239 +1,13 @@ # -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') +from __future__ import absolute_import + +from rest_framework import serializers +from kpi.constants import PERM_VIEW_COLLECTION +from kpi.fields import RelativePrefixHyperlinkedRelatedField +from kpi.models import Collection +from kpi.models import UserCollectionSubscription +from kpi.models.object_permission import get_anonymous_user +from kpi.models.object_permission import get_objects_for_user class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): From be8be01d861bdd9b1ac3f8fe62f2db09c4b82604 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 11:27:53 -0400 Subject: [PATCH 030/499] Moved signals from models to signals.py --- kpi/signals.py | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/kpi/signals.py b/kpi/signals.py index f38b87e09a..0114c140e4 100644 --- a/kpi/signals.py +++ b/kpi/signals.py @@ -1,12 +1,6 @@ -<<<<<<< HEAD - # coding: utf-8 from django.conf import settings from django.contrib.auth.models import User -======= -# -*- coding: utf-8 -*- -from django.conf import settings ->>>>>>> Removed KC db connection when running tests from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from rest_framework.authtoken.models import Token @@ -22,7 +16,6 @@ from kpi.utils.permissions import grant_default_model_level_perms - @receiver(post_save, sender=User) def default_permissions_post_save(sender, instance, created, raw, **kwargs): """ @@ -49,7 +42,6 @@ def save_kobocat_user(sender, instance, created, raw, **kwargs): `settings.KOBOCAT_DEFAULT_PERMISSION_CONTENT_TYPES` """ if not settings.TESTING: -<<<<<<< HEAD KobocatUser.sync(instance) if created: @@ -60,9 +52,6 @@ def save_kobocat_user(sender, instance, created, raw, **kwargs): # seem to help. We should roll back the KC user creation if # assigning model-level permissions fails grant_kc_model_level_perms(instance) -======= - KCUser.sync(instance) ->>>>>>> Removed KC db connection when running tests @receiver(post_save, sender=Token) @@ -104,15 +93,13 @@ def update_kc_xform_has_kpi_hooks(sender, instance, **kwargs): asset.deployment.set_has_kpi_hooks() -@receiver(post_delete, sender=Collection) -def post_delete_collection(sender, instance, **kwargs): - # Remove all permissions associated with this object - ObjectPermission.objects.filter_for_object(instance).delete() - # No recalculation is necessary since children will also be deleted - - @receiver(post_delete, sender=Asset) def post_delete_asset(sender, instance, **kwargs): - # Remove all permissions associated with this object - ObjectPermission.objects.filter_for_object(instance).delete() - # No recalculation is necessary since children will also be deleted + # Update parent's languages if this object is a child of another asset. + try: + parent = instance.parent + except Asset.DoesNotExist: # `parent` may exists in DJANGO models cache but not in DB + pass + else: + if parent: + parent.update_languages() From 5a00069e4e0c661c40b07d0e7e46b6d11df5c302 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 11:28:30 -0400 Subject: [PATCH 031/499] PEP-8 docstring compliance --- kpi/models/asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index df8b796d12..b90cc6dc40 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -1407,4 +1407,4 @@ class UserAssetSubscription(models.Model): uid = KpiUidField(uid_prefix='b') class Meta: - unique_together = ('asset', 'user') \ No newline at end of file + unique_together = ('asset', 'user') From a88a3619255456fdd91ecebe22b1594f3f531542 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 11:42:12 -0400 Subject: [PATCH 032/499] WIP - Add new routes for api '/api/v2' --- kpi/urls.py | 139 ---------------------------------------------------- 1 file changed, 139 deletions(-) delete mode 100644 kpi/urls.py diff --git a/kpi/urls.py b/kpi/urls.py deleted file mode 100644 index 352698f16f..0000000000 --- a/kpi/urls.py +++ /dev/null @@ -1,139 +0,0 @@ -from django.conf.urls import url, include -from django.views.i18n import javascript_catalog -from hub.views import ExtraDetailRegistrationView -from rest_framework_extensions.routers import ExtendedDefaultRouter -import private_storage.urls - -from hub.models import ConfigurationFile -from hub.views import switch_builder -from kobo.apps.hook.views import HookViewSet, HookLogViewSet -from kobo.apps.reports.views import ReportsViewSet -from kpi.forms import RegistrationForm -from kpi.views import ( - AssetViewSet, - AssetVersionViewSet, - AssetSnapshotViewSet, - AssetFileViewSet, - HookSignalViewSet, - SubmissionViewSet, - UserViewSet, - CurrentUserViewSet, - CollectionViewSet, - TagViewSet, - ImportTaskViewSet, - ExportTaskViewSet, - ObjectPermissionViewSet, - SitewideMessageViewSet, - AuthorizedApplicationUserViewSet, - OneTimeAuthenticationKeyViewSet, - UserCollectionSubscriptionViewSet, - TokenView, - EnvironmentView, -) - -from kpi.views import authorized_application_authenticate_user -from kpi.views import home, one_time_login, browser_tests - - -# TODO: Give other apps their own `urls.py` files instead of importing their -# views directly! See -# https://docs.djangoproject.com/en/1.8/intro/tutorial03/#namespacing-url-names - -router = ExtendedDefaultRouter() -asset_routes = router.register(r'assets', AssetViewSet, base_name='asset') -asset_routes.register(r'versions', - AssetVersionViewSet, - base_name='asset-version', - parents_query_lookups=['asset'], - ) -asset_routes.register(r'hook-signal', - HookSignalViewSet, - base_name='hook-signal', - parents_query_lookups=['asset'], - ) -asset_routes.register(r'submissions', - SubmissionViewSet, - base_name='submission', - parents_query_lookups=['asset'], - ) -asset_routes.register(r'files', - AssetFileViewSet, - base_name='asset-file', - parents_query_lookups=['asset'], - ) - -hook_routes = asset_routes.register(r'hooks', - HookViewSet, - base_name='hook', - parents_query_lookups=['asset'], - ) - -hook_routes.register(r'logs', - HookLogViewSet, - base_name='hook-log', - parents_query_lookups=['asset', 'hook'], - ) - -router.register(r'asset_snapshots', AssetSnapshotViewSet) -router.register( - r'collection_subscriptions', UserCollectionSubscriptionViewSet) -router.register(r'collections', CollectionViewSet) -router.register(r'users', UserViewSet) -router.register(r'tags', TagViewSet) -router.register(r'permissions', ObjectPermissionViewSet) -router.register(r'reports', ReportsViewSet, base_name='reports') -router.register(r'imports', ImportTaskViewSet) -router.register(r'exports', ExportTaskViewSet) -router.register(r'sitewide_messages', SitewideMessageViewSet) - -router.register(r'authorized_application/users', - AuthorizedApplicationUserViewSet, - base_name='authorized_applications') -router.register(r'authorized_application/one_time_authentication_keys', - OneTimeAuthenticationKeyViewSet) - -# Apps whose translations should be available in the client code. -js_info_dict = { - 'packages': ('kobo.apps.KpiConfig',), -} - -urlpatterns = [ - url(r'^$', home, name='kpi-root'), - url(r'^me/$', CurrentUserViewSet.as_view({ - 'get': 'retrieve', - 'patch': 'partial_update', - }), name='currentuser-detail'), - url(r'^grant-default-model-level-perms$', CurrentUserViewSet.as_view({ - 'post': 'grant_default_model_level_perms', - }), name='currentuser-detail'), - url(r'^', include(router.urls)), - url(r'^api-auth/', include('rest_framework.urls', - namespace='rest_framework')), - url(r'^accounts/register/$', ExtraDetailRegistrationView.as_view( - form_class=RegistrationForm), name='registration_register'), - url(r'^accounts/logout/', 'django.contrib.auth.views.logout', - {'next_page': '/'}), - url(r'^accounts/', include('registration.backends.default.urls')), - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), - url( - r'^authorized_application/authenticate_user/$', - authorized_application_authenticate_user - ), - url(r'^browser_tests/$', browser_tests), - url(r'^authorized_application/one_time_login/$', one_time_login), - url(r'^hub/switch_builder$', switch_builder, name='toggle-preferred-builder'), - url(r'^i18n/', include('django.conf.urls.i18n')), - # Translation catalog for client code. - url(r'^jsi18n/$', javascript_catalog, js_info_dict, name='javascript-catalog'), - # url(r'^.*', home), - url(r'^token/$', TokenView.as_view(), name='token'), - url(r'^environment/$', EnvironmentView.as_view(), name='environment'), - url(r'^configurationfile/(?P[^/]+)/?', - ConfigurationFile.redirect_view, name='configurationfile'), - url(r'^private-media/', include(private_storage.urls)), - # Statistics for superusers - url(r'^superuser_stats/user_report/$', - 'kobo.apps.superuser_stats.views.user_report'), - url(r'^superuser_stats/user_report/(?P[^/]+)$', - 'kobo.apps.superuser_stats.views.retrieve_user_report'), -] From 37426650e91c53b01f759b4957dc1e0259e5103f Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 11:57:22 -0400 Subject: [PATCH 033/499] WIP - Use separate router,views,serializer for api v1 and v2 --- kpi/urls/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/kpi/urls/__init__.py b/kpi/urls/__init__.py index b94293e458..5072730991 100644 --- a/kpi/urls/__init__.py +++ b/kpi/urls/__init__.py @@ -1,5 +1,6 @@ # coding: utf-8 import private_storage.urls +<<<<<<< HEAD from django.contrib.auth import logout from django.urls import include, re_path, path from django.views.i18n import JavaScriptCatalog @@ -8,6 +9,14 @@ from hub.views import ExtraDetailRegistrationView from kobo.apps.superuser_stats.views import user_report, retrieve_user_report from kpi.forms import RegistrationForm +======= + +from hub.models import ConfigurationFile +from hub.views import ExtraDetailRegistrationView +from hub.views import switch_builder +from kpi.forms import RegistrationForm +from kpi.views import CurrentUserViewSet, TokenView, EnvironmentView +>>>>>>> WIP - Use separate router,views,serializer for api v1 and v2 from kpi.views import authorized_application_authenticate_user from kpi.views import home, one_time_login, browser_tests from kpi.views.environment import EnvironmentView @@ -17,6 +26,9 @@ from .router_api_v1 import router_api_v1 from .router_api_v2 import router_api_v2, URL_NAMESPACE +from .router_api_v1 import router_api_v1 +from .router_api_v2 import router_api_v2 + # TODO: Give other apps their own `urls.py` files instead of importing their # views directly! See # https://docs.djangoproject.com/en/1.8/intro/tutorial03/#namespacing-url-names @@ -29,6 +41,7 @@ 'get': 'retrieve', 'patch': 'partial_update', }), name='currentuser-detail'), +<<<<<<< HEAD re_path(r'^grant-default-model-level-perms$', CurrentUserViewSet.as_view({ 'post': 'grant_default_model_level_perms', }), name='currentuser-detail'), @@ -37,6 +50,13 @@ re_path(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), re_path(r'^accounts/register/$', ExtraDetailRegistrationView.as_view( +======= + url(r'^', include(router_api_v1.urls)), + url(r'^api/v2/', include(router_api_v2.urls, namespace='api_v2')), + url(r'^api-auth/', include('rest_framework.urls', + namespace='rest_framework')), + url(r'^accounts/register/$', ExtraDetailRegistrationView.as_view( +>>>>>>> WIP - Use separate router,views,serializer for api v1 and v2 form_class=RegistrationForm), name='registration_register'), re_path(r'^accounts/logout/', logout, {'next_page': '/'}), re_path(r'^accounts/', include('registration.backends.default.urls')), From f7c41c24836a677ecdb2c6dc0122a80a908d5d9d Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 12:14:33 -0400 Subject: [PATCH 034/499] Moved current views and serializers under 'v1' parent folder --- kpi/serializers/__init__.py | 2 +- kpi/serializers/ancestor_collections.py | 15 - kpi/serializers/asset.py | 290 ---------- kpi/serializers/asset_file.py | 47 -- kpi/serializers/asset_snapshot.py | 110 ---- kpi/serializers/asset_version.py | 44 -- .../authorized_application_user.py | 25 - kpi/serializers/collection.py | 151 ----- kpi/serializers/deployment.py | 73 --- kpi/serializers/export_task.py | 43 -- kpi/serializers/import_task.py | 42 -- kpi/serializers/object_permission.py | 84 --- .../one_time_authentication_key.py | 16 - kpi/serializers/sitewide_message.py | 14 - kpi/serializers/tag.py | 61 -- kpi/serializers/user.py | 197 ------- .../user_collection_subscription.py | 37 -- kpi/views/asset.py | 541 ------------------ kpi/views/asset_file.py | 63 -- kpi/views/asset_snapshot.py | 80 --- kpi/views/asset_version.py | 37 -- kpi/views/authorized_application_user.py | 24 - kpi/views/collection.py | 103 ---- kpi/views/export_task.py | 96 ---- kpi/views/import_task.py | 65 --- kpi/views/object_permission.py | 67 --- kpi/views/one_time_authentication_key.py | 27 - kpi/views/sitewide_message.py | 12 - kpi/views/submission.py | 239 -------- kpi/views/tag.py | 59 -- kpi/views/user.py | 25 - kpi/views/user_collection_subscription.py | 30 - kpi/views/v1/__init__.py | 5 + kpi/views/v1/asset.py | 396 +++++++++++++ kpi/views/v1/asset_file.py | 66 +++ kpi/views/v1/asset_snapshot.py | 83 +++ kpi/views/v1/asset_version.py | 37 ++ kpi/views/v1/authorized_application_user.py | 13 + kpi/views/v1/collection.py | 100 ++++ kpi/views/{ => v1}/current_user.py | 0 kpi/views/{ => v1}/environment.py | 0 kpi/views/v1/export_task.py | 18 + kpi/views/{ => v1}/hook_signal.py | 0 kpi/views/v1/import_task.py | 21 + kpi/views/{ => v1}/no_update_model.py | 0 kpi/views/v1/object_permission.py | 44 ++ kpi/views/v1/one_time_authentication_key.py | 10 + kpi/views/v1/sitewide_message.py | 6 + kpi/views/v1/submission.py | 116 ++++ kpi/views/v1/tag.py | 10 + kpi/views/{ => v1}/token.py | 0 kpi/views/v1/user.py | 28 + kpi/views/v1/user_collection_subscription.py | 10 + 53 files changed, 964 insertions(+), 2718 deletions(-) delete mode 100644 kpi/serializers/ancestor_collections.py delete mode 100644 kpi/serializers/asset.py delete mode 100644 kpi/serializers/asset_file.py delete mode 100644 kpi/serializers/asset_snapshot.py delete mode 100644 kpi/serializers/asset_version.py delete mode 100644 kpi/serializers/authorized_application_user.py delete mode 100644 kpi/serializers/collection.py delete mode 100644 kpi/serializers/deployment.py delete mode 100644 kpi/serializers/export_task.py delete mode 100644 kpi/serializers/import_task.py delete mode 100644 kpi/serializers/object_permission.py delete mode 100644 kpi/serializers/one_time_authentication_key.py delete mode 100644 kpi/serializers/sitewide_message.py delete mode 100644 kpi/serializers/tag.py delete mode 100644 kpi/serializers/user.py delete mode 100644 kpi/serializers/user_collection_subscription.py delete mode 100644 kpi/views/asset.py delete mode 100644 kpi/views/asset_file.py delete mode 100644 kpi/views/asset_snapshot.py delete mode 100644 kpi/views/asset_version.py delete mode 100644 kpi/views/authorized_application_user.py delete mode 100644 kpi/views/collection.py delete mode 100644 kpi/views/export_task.py delete mode 100644 kpi/views/import_task.py delete mode 100644 kpi/views/object_permission.py delete mode 100644 kpi/views/one_time_authentication_key.py delete mode 100644 kpi/views/sitewide_message.py delete mode 100644 kpi/views/submission.py delete mode 100644 kpi/views/tag.py delete mode 100644 kpi/views/user.py delete mode 100644 kpi/views/user_collection_subscription.py rename kpi/views/{ => v1}/current_user.py (100%) rename kpi/views/{ => v1}/environment.py (100%) rename kpi/views/{ => v1}/hook_signal.py (100%) rename kpi/views/{ => v1}/no_update_model.py (100%) rename kpi/views/{ => v1}/token.py (100%) diff --git a/kpi/serializers/__init__.py b/kpi/serializers/__init__.py index df9837de56..eb1edae4f9 100644 --- a/kpi/serializers/__init__.py +++ b/kpi/serializers/__init__.py @@ -21,4 +21,4 @@ from .create_user import CreateUserSerializer from .current_user import CurrentUserSerializer from .v1.user import UserSerializer -from .v1.user_asset_subscription import UserAssetSubscriptionSerializer \ No newline at end of file +from .v1.user_asset_subscription import UserAssetSubscriptionSerializer diff --git a/kpi/serializers/ancestor_collections.py b/kpi/serializers/ancestor_collections.py deleted file mode 100644 index e9478ebd43..0000000000 --- a/kpi/serializers/ancestor_collections.py +++ /dev/null @@ -1,15 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers - -from kpi.models import Collection - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') diff --git a/kpi/serializers/asset.py b/kpi/serializers/asset.py deleted file mode 100644 index b56ae7c08c..0000000000 --- a/kpi/serializers/asset.py +++ /dev/null @@ -1,290 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -import json - -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.fields import RelativePrefixHyperlinkedRelatedField, WritableJSONField, \ - PaginatedApiField -from kpi.models import Asset, AssetVersion, Collection -from kpi.models.asset import ASSET_TYPES -from kpi.models.object_permission import get_anonymous_user - -from .ancestor_collections import AncestorCollectionsSerializer -from .asset_version import AssetVersionListSerializer -from .object_permission import ObjectPermissionNestedSerializer - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) diff --git a/kpi/serializers/asset_file.py b/kpi/serializers/asset_file.py deleted file mode 100644 index 75bfd5f823..0000000000 --- a/kpi/serializers/asset_file.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.fields import RelativePrefixHyperlinkedRelatedField, \ - SerializerMethodFileField, WritableJSONField -from kpi.models import AssetFile - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) diff --git a/kpi/serializers/asset_snapshot.py b/kpi/serializers/asset_snapshot.py deleted file mode 100644 index b3ad476411..0000000000 --- a/kpi/serializers/asset_snapshot.py +++ /dev/null @@ -1,110 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import exceptions -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.constants import PERM_VIEW_ASSET -from kpi.fields import RelativePrefixHyperlinkedRelatedField, WritableJSONField -from kpi.models import Asset -from kpi.models import AssetSnapshot - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) diff --git a/kpi/serializers/asset_version.py b/kpi/serializers/asset_version.py deleted file mode 100644 index 5dba04947e..0000000000 --- a/kpi/serializers/asset_version.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.models import AssetVersion - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) diff --git a/kpi/serializers/authorized_application_user.py b/kpi/serializers/authorized_application_user.py deleted file mode 100644 index 0c6987299a..0000000000 --- a/kpi/serializers/authorized_application_user.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers, exceptions - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data diff --git a/kpi/serializers/collection.py b/kpi/serializers/collection.py deleted file mode 100644 index 17bed2ae71..0000000000 --- a/kpi/serializers/collection.py +++ /dev/null @@ -1,151 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.fields import RelativePrefixHyperlinkedRelatedField, PaginatedApiField -from kpi.models import Asset -from kpi.models import Collection -from kpi.models import CollectionChildrenQuerySet - -from .asset import AssetListSerializer -from .ancestor_collections import AncestorCollectionsSerializer -from .object_permission import ObjectPermissionSerializer - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) diff --git a/kpi/serializers/deployment.py b/kpi/serializers/deployment.py deleted file mode 100644 index d89b1eb569..0000000000 --- a/kpi/serializers/deployment.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.conf import settings -from rest_framework import serializers -from rest_framework import exceptions - -from .asset import AssetSerializer - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment diff --git a/kpi/serializers/export_task.py b/kpi/serializers/export_task.py deleted file mode 100644 index a70c666041..0000000000 --- a/kpi/serializers/export_task.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers - -from kpi.fields import ReadOnlyJSONField -from kpi.models import ExportTask - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } diff --git a/kpi/serializers/import_task.py b/kpi/serializers/import_task.py deleted file mode 100644 index 9f494be75f..0000000000 --- a/kpi/serializers/import_task.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers -from kpi.fields import ReadOnlyJSONField -from kpi.models import ImportTask - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - diff --git a/kpi/serializers/object_permission.py b/kpi/serializers/object_permission.py deleted file mode 100644 index 5df635f8b5..0000000000 --- a/kpi/serializers/object_permission.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -from django.db import transaction -from rest_framework import serializers - -from kpi.constants import PERM_FROM_KC_ONLY -from kpi.fields import GenericHyperlinkedRelatedField, \ - RelativePrefixHyperlinkedRelatedField -from kpi.models import ObjectPermission - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'deny', - 'inherited', - ) diff --git a/kpi/serializers/one_time_authentication_key.py b/kpi/serializers/one_time_authentication_key.py deleted file mode 100644 index 9cf511f7ee..0000000000 --- a/kpi/serializers/one_time_authentication_key.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.contrib.auth.models import User -from rest_framework import serializers - -from kpi.models import OneTimeAuthenticationKey - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') diff --git a/kpi/serializers/sitewide_message.py b/kpi/serializers/sitewide_message.py deleted file mode 100644 index e92586ec01..0000000000 --- a/kpi/serializers/sitewide_message.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers - -from hub.models import SitewideMessage - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) diff --git a/kpi/serializers/tag.py b/kpi/serializers/tag.py deleted file mode 100644 index 8d8303a563..0000000000 --- a/kpi/serializers/tag.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers -from rest_framework.reverse import reverse -from taggit.models import Tag - -from kpi.models import Asset, Collection, TagUid -from kpi.models.object_permission import get_anonymous_user - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) diff --git a/kpi/serializers/user.py b/kpi/serializers/user.py deleted file mode 100644 index 859738df69..0000000000 --- a/kpi/serializers/user.py +++ /dev/null @@ -1,197 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -import datetime -import pytz - -from django.contrib.auth.models import User -from django.db import transaction -from django.conf import settings -from rest_framework import serializers - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from hub.models import ExtraUserDetail -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth -from kpi.fields import PaginatedApiField, WritableJSONField -from kpi.forms import USERNAME_REGEX -from kpi.forms import USERNAME_MAX_LENGTH -from kpi.forms import USERNAME_INVALID_MESSAGE -from kpi.utils.gravatar_url import gravatar_url - -from .asset import AssetUrlListSerializer - - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } diff --git a/kpi/serializers/user_collection_subscription.py b/kpi/serializers/user_collection_subscription.py deleted file mode 100644 index 3a6455858a..0000000000 --- a/kpi/serializers/user_collection_subscription.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework import serializers -from kpi.constants import PERM_VIEW_COLLECTION -from kpi.fields import RelativePrefixHyperlinkedRelatedField -from kpi.models import Collection -from kpi.models import UserCollectionSubscription -from kpi.models.object_permission import get_anonymous_user -from kpi.models.object_permission import get_objects_for_user - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') diff --git a/kpi/views/asset.py b/kpi/views/asset.py deleted file mode 100644 index cf6b8eaab9..0000000000 --- a/kpi/views/asset.py +++ /dev/null @@ -1,541 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -import copy -import json -from hashlib import md5 - -from django.http import Http404 -from django.shortcuts import get_object_or_404 -from rest_framework import exceptions, renderers, status, viewsets -from rest_framework.decorators import detail_route, list_route -from rest_framework.response import Response -from rest_framework_extensions.mixins import NestedViewSetMixin - -from kpi.constants import ASSET_TYPES, ASSET_TYPE_ARG_NAME, ASSET_TYPE_SURVEY, \ - ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - CLONE_FROM_VERSION_ID_ARG_NAME, PERM_SHARE_ASSET, PERM_VIEW_ASSET -from kpi.deployment_backends.backends import DEPLOYMENT_BACKENDS -from kpi.exceptions import BadAssetTypeException -from kpi.filters import KpiObjectPermissionsFilter, SearchFilter -from kpi.highlighters import highlight_xform -from kpi.models import Asset -from kpi.models.object_permission import get_anonymous_user, get_objects_for_user -from kpi.permissions import IsOwnerOrReadOnly, PostMappedToChangePermission, \ - get_perm_name -from kpi.renderers import AssetJsonRenderer, SSJsonRenderer, XFormRenderer, \ - XlsRenderer -from kpi.serializers import AssetListSerializer, AssetSerializer, DeploymentSerializer -from kpi.utils.kobo_to_xlsform import to_xlsform_structure -from kpi.utils.ss_structure_to_mdtable import ss_structure_to_mdtable - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY) \ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        """
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        """
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-                user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        """ Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        """
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
diff --git a/kpi/views/asset_file.py b/kpi/views/asset_file.py
deleted file mode 100644
index 8bbdf6cf41..0000000000
--- a/kpi/views/asset_file.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from private_storage.views import PrivateStorageDetailView
-from rest_framework import exceptions
-from rest_framework.decorators import detail_route
-from rest_framework_extensions.mixins import NestedViewSetMixin
-
-from kpi.constants import PERM_CHANGE_ASSET, PERM_VIEW_ASSET
-from kpi.filters import RelatedAssetPermissionsFilter
-from kpi.models import Asset, AssetFile
-from kpi.serializers import AssetFileSerializer
-
-from .no_update_model import NoUpdateModelViewSet
-
-
-class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet):
-    model = AssetFile
-    lookup_field = 'uid'
-    filter_backends = (RelatedAssetPermissionsFilter,)
-    serializer_class = AssetFileSerializer
-
-    def get_queryset(self):
-        _asset_uid = self.get_parents_query_dict()['asset']
-        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
-        return _queryset
-
-    def perform_create(self, serializer):
-        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
-        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
-            raise exceptions.PermissionDenied()
-        serializer.save(
-            asset=asset,
-            user=self.request.user
-        )
-
-    def perform_destroy(self, *args, **kwargs):
-        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
-        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
-            raise exceptions.PermissionDenied()
-        return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs)
-
-    class PrivateContentView(PrivateStorageDetailView):
-        model = AssetFile
-        model_file_field = 'content'
-
-        def can_access_file(self, private_file):
-            return private_file.request.user.has_perm(
-                PERM_VIEW_ASSET, private_file.parent_object.asset)
-
-    @detail_route(methods=['get'])
-    def content(self, *args, **kwargs):
-        view = self.PrivateContentView.as_view(
-            model=AssetFile,
-            slug_url_kwarg='uid',
-            slug_field='uid',
-            model_file_field='content'
-        )
-        af = self.get_object()
-        # TODO: simply redirect if external storage with expiring tokens (e.g.
-        # Amazon S3) is used?
-        #   return HttpResponseRedirect(af.content.url)
-        return view(self.request, uid=af.uid)
diff --git a/kpi/views/asset_snapshot.py b/kpi/views/asset_snapshot.py
deleted file mode 100644
index 0ec7dea6a9..0000000000
--- a/kpi/views/asset_snapshot.py
+++ /dev/null
@@ -1,80 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-import copy
-
-from django.http import HttpResponseRedirect
-from django.conf import settings
-from rest_framework import renderers
-from rest_framework.decorators import detail_route, list_route
-from rest_framework.response import Response
-from rest_framework.reverse import reverse
-
-from kpi.filters import RelatedAssetPermissionsFilter
-from kpi.highlighters import highlight_xform
-from kpi.models import AssetSnapshot
-from kpi.renderers import XMLRenderer
-from kpi.serializers import AssetSnapshotSerializer
-
-from .no_update_model import NoUpdateModelViewSet
-
-
-class AssetSnapshotViewSet(NoUpdateModelViewSet):
-    serializer_class = AssetSnapshotSerializer
-    lookup_field = 'uid'
-    queryset = AssetSnapshot.objects.all()
-
-    renderer_classes = NoUpdateModelViewSet.renderer_classes + [
-        XMLRenderer,
-    ]
-
-    def filter_queryset(self, queryset):
-        if (self.action == 'retrieve' and
-                self.request.accepted_renderer.format == 'xml'):
-            # The XML renderer is totally public and serves anyone, so
-            # /asset_snapshot/valid_uid.xml is world-readable, even though
-            # /asset_snapshot/valid_uid/ requires ownership. Return the
-            # queryset unfiltered
-            return queryset
-        else:
-            user = self.request.user
-            owned_snapshots = queryset.none()
-            if not user.is_anonymous():
-                owned_snapshots = queryset.filter(owner=user)
-            return owned_snapshots | RelatedAssetPermissionsFilter(
-                ).filter_queryset(self.request, queryset, view=self)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        """
-        This route will render the XForm into syntax-highlighted HTML.
-        It is useful for debugging pyxform transformations
-        """
-        snapshot = self.get_object()
-        response_data = copy.copy(snapshot.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if snapshot.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(snapshot.xml,
-                                                                 **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def preview(self, request, *args, **kwargs):
-        snapshot = self.get_object()
-        if snapshot.details.get('status') == 'success':
-            preview_url = "{}{}?form={}".format(
-                              settings.ENKETO_SERVER,
-                              settings.ENKETO_PREVIEW_URI,
-                              reverse(viewname='assetsnapshot-detail',
-                                      format='xml',
-                                      kwargs={'uid': snapshot.uid},
-                                      request=request,
-                                      ),
-                            )
-            return HttpResponseRedirect(preview_url)
-        else:
-            response_data = copy.copy(snapshot.details)
-            return Response(response_data, template_name='preview_error.html')
diff --git a/kpi/views/asset_version.py b/kpi/views/asset_version.py
deleted file mode 100644
index 223c713b8d..0000000000
--- a/kpi/views/asset_version.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from rest_framework import viewsets
-from rest_framework_extensions.mixins import NestedViewSetMixin
-from kpi.filters import AssetOwnerFilterBackend
-from kpi.models import AssetVersion
-from kpi.serializers import AssetVersionListSerializer, AssetVersionSerializer
-
-
-class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet):
-    model = AssetVersion
-    lookup_field = 'uid'
-    filter_backends = (
-            AssetOwnerFilterBackend,
-        )
-
-    def get_serializer_class(self):
-        if self.action == 'list':
-            return AssetVersionListSerializer
-        else:
-            return AssetVersionSerializer
-
-    def get_queryset(self):
-        _asset_uid = self.get_parents_query_dict()['asset']
-        _deployed = self.request.query_params.get('deployed', None)
-        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
-        if _deployed is not None:
-            _queryset = _queryset.filter(deployed=_deployed)
-        if self.action == 'list':
-            # Save time by only retrieving fields from the DB that the
-            # serializer will use
-            _queryset = _queryset.only(
-                'uid', 'deployed', 'date_modified', 'asset_id')
-        # `AssetVersionListSerializer.get_url()` asks for the asset UID
-        _queryset = _queryset.select_related('asset__uid')
-        return _queryset
diff --git a/kpi/views/authorized_application_user.py b/kpi/views/authorized_application_user.py
deleted file mode 100644
index 1d5bb397dd..0000000000
--- a/kpi/views/authorized_application_user.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.contrib.auth.models import User
-from rest_framework import viewsets, mixins, exceptions
-from kpi.models import AuthorizedApplication
-from kpi.models.authorized_application import ApplicationTokenAuthentication
-from kpi.serializers import CreateUserSerializer
-
-
-class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin,
-                                       viewsets.GenericViewSet):
-    authentication_classes = [ApplicationTokenAuthentication]
-    queryset = User.objects.all()
-    serializer_class = CreateUserSerializer
-    lookup_field = 'username'
-
-    def create(self, request, *args, **kwargs):
-        if type(request.auth) is not AuthorizedApplication:
-            # Only specially-authorized applications are allowed to create
-            # users via this endpoint
-            raise exceptions.PermissionDenied()
-        return super(AuthorizedApplicationUserViewSet, self).create(
-            request, *args, **kwargs)
diff --git a/kpi/views/collection.py b/kpi/views/collection.py
deleted file mode 100644
index cf1e096978..0000000000
--- a/kpi/views/collection.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.forms import model_to_dict
-from django.http import Http404
-from django.shortcuts import get_object_or_404
-from rest_framework import viewsets, status, exceptions
-from rest_framework.response import Response
-from kpi.filters import KpiObjectPermissionsFilter, SearchFilter
-from kpi.models import Collection
-from kpi.model_utils import disable_auto_field_update
-from kpi.permissions import IsOwnerOrReadOnly, get_perm_name
-from kpi.serializers import CollectionSerializer, CollectionListSerializer
-from kpi.constants import CLONE_ARG_NAME, COLLECTION_CLONE_FIELDS
-
-
-class CollectionViewSet(viewsets.ModelViewSet):
-    # Filtering handled by KpiObjectPermissionsFilter.filter_queryset()
-    queryset = Collection.objects.select_related(
-        'owner', 'parent'
-    ).prefetch_related(
-        'permissions',
-        'permissions__permission',
-        'permissions__user',
-        'permissions__content_object',
-        'usercollectionsubscription_set',
-    ).all().order_by('-date_modified')
-    serializer_class = CollectionSerializer
-    permission_classes = (IsOwnerOrReadOnly,)
-    filter_backends = (KpiObjectPermissionsFilter, SearchFilter)
-    lookup_field = 'uid'
-
-    def _clone(self):
-        # Clone an existing collection.
-        original_uid = self.request.data[CLONE_ARG_NAME]
-        original_collection = get_object_or_404(Collection, uid=original_uid)
-        view_perm = get_perm_name('view', original_collection)
-        if not self.request.user.has_perm(view_perm, original_collection):
-            raise Http404
-        else:
-            # Copy the essential data from the original collection.
-            original_data= model_to_dict(original_collection)
-            cloned_data= {keep_field: original_data[keep_field]
-                          for keep_field in COLLECTION_CLONE_FIELDS}
-            if original_collection.tag_string:
-                cloned_data['tag_string']= original_collection.tag_string
-
-            # Pull any additionally provided parameters/overrides from the
-            # request.
-            for param in self.request.data:
-                cloned_data[param] = self.request.data[param]
-            serializer = self.get_serializer(data=cloned_data)
-            serializer.is_valid(raise_exception=True)
-            self.perform_create(serializer)
-
-            headers = self.get_success_headers(serializer.data)
-            return Response(serializer.data, status=status.HTTP_201_CREATED,
-                            headers=headers)
-
-    def create(self, request, *args, **kwargs):
-        if CLONE_ARG_NAME not in request.data:
-            return super(CollectionViewSet, self).create(request, *args,
-                                                         **kwargs)
-        else:
-            return self._clone()
-
-    def perform_create(self, serializer):
-        serializer.save(owner=self.request.user)
-
-    def perform_update(self, serializer, *args, **kwargs):
-        """ Only the owner is allowed to change `discoverable_when_public` """
-        original_collection = self.get_object()
-        if (self.request.user != original_collection.owner and
-                'discoverable_when_public' in serializer.validated_data and
-                (serializer.validated_data['discoverable_when_public'] !=
-                    original_collection.discoverable_when_public)
-        ):
-            raise exceptions.PermissionDenied()
-
-        # Some fields shouldn't affect the modification date
-        FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set((
-            'discoverable_when_public',
-        ))
-        changed_fields = set()
-        for k, v in serializer.validated_data.iteritems():
-            if getattr(original_collection, k) != v:
-                changed_fields.add(k)
-        if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE):
-            with disable_auto_field_update(Collection, 'date_modified'):
-                return super(CollectionViewSet, self).perform_update(
-                    serializer, *args, **kwargs)
-
-        return super(CollectionViewSet, self).perform_update(
-                serializer, *args, **kwargs)
-
-    def perform_destroy(self, instance):
-        instance.delete_with_deferred_indexing()
-
-    def get_serializer_class(self):
-        if self.action == 'list':
-            return CollectionListSerializer
-        else:
-            return CollectionSerializer
diff --git a/kpi/views/export_task.py b/kpi/views/export_task.py
deleted file mode 100644
index d6f2c17ea5..0000000000
--- a/kpi/views/export_task.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from rest_framework import status, exceptions
-from rest_framework.response import Response
-from rest_framework.reverse import reverse
-
-from kpi.models import ExportTask
-from kpi.models.import_export_task import _resolve_url_to_asset_or_collection
-from kpi.model_utils import remove_string_prefix
-from kpi.serializers import ExportTaskSerializer
-from kpi.tasks import export_in_background
-from .no_update_model import NoUpdateModelViewSet
-
-
-class ExportTaskViewSet(NoUpdateModelViewSet):
-    queryset = ExportTask.objects.all()
-    serializer_class = ExportTaskSerializer
-    lookup_field = 'uid'
-
-    def get_queryset(self, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            return ExportTask.objects.none()
-
-        queryset = ExportTask.objects.filter(
-            user=self.request.user).order_by('date_created')
-
-        # Ultra-basic filtering by:
-        # * source URL or UID if `q=source:[URL|UID]` was provided;
-        # * comma-separated list of `ExportTask` UIDs if
-        #   `q=uid__in:[UID],[UID],...` was provided
-        q = self.request.query_params.get('q', False)
-        if not q:
-            # No filter requested
-            return queryset
-        if q.startswith('source:'):
-            q = remove_string_prefix(q, 'source:')
-            # This is exceedingly crude... but support for querying inside
-            # JSONField not available until Django 1.9
-            queryset = queryset.filter(data__contains=q)
-        elif q.startswith('uid__in:'):
-            q = remove_string_prefix(q, 'uid__in:')
-            uids = [uid.strip() for uid in q.split(',')]
-            queryset = queryset.filter(uid__in=uids)
-        else:
-            # Filter requested that we don't understand; make it obvious by
-            # returning nothing
-            return ExportTask.objects.none()
-        return queryset
-
-    def create(self, request, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            raise exceptions.NotAuthenticated()
-
-        # Read valid options from POST data
-        valid_options = (
-            'type',
-            'source',
-            'group_sep',
-            'lang',
-            'hierarchy_in_labels',
-            'fields_from_all_versions',
-        )
-        task_data = {}
-        for opt in valid_options:
-            opt_val = request.POST.get(opt, None)
-            if opt_val is not None:
-                task_data[opt] = opt_val
-        # Complain if no source was specified
-        if not task_data.get('source', False):
-            raise exceptions.ValidationError(
-                {'source': 'This field is required.'})
-        # Get the source object
-        source_type, source = _resolve_url_to_asset_or_collection(
-            task_data['source'])
-        # Complain if it's not an Asset
-        if source_type != 'asset':
-            raise exceptions.ValidationError(
-                {'source': 'This field must specify an asset.'})
-        # Complain if it's not deployed
-        if not source.has_deployment:
-            raise exceptions.ValidationError(
-                {'source': 'The specified asset must be deployed.'})
-        # Create a new export task
-        export_task = ExportTask.objects.create(user=request.user,
-                                                data=task_data)
-        # Have Celery run the export in the background
-        export_in_background.delay(export_task_uid=export_task.uid)
-        return Response({
-            'uid': export_task.uid,
-            'url': reverse(
-                'exporttask-detail',
-                kwargs={'uid': export_task.uid},
-                request=request),
-            'status': ExportTask.PROCESSING
-        }, status.HTTP_201_CREATED)
diff --git a/kpi/views/import_task.py b/kpi/views/import_task.py
deleted file mode 100644
index 56b10d5317..0000000000
--- a/kpi/views/import_task.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-import base64
-
-from rest_framework import exceptions, status, viewsets
-from rest_framework.response import Response
-from rest_framework.reverse import reverse
-
-from kpi.models import ImportTask
-from kpi.serializers import ImportTaskListSerializer, ImportTaskSerializer
-from kpi.tasks import import_in_background
-
-
-class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet):
-    queryset = ImportTask.objects.all()
-    serializer_class = ImportTaskSerializer
-    lookup_field = 'uid'
-
-    def get_serializer_class(self):
-        if self.action == 'list':
-            return ImportTaskListSerializer
-        else:
-            return ImportTaskSerializer
-
-    def get_queryset(self, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            return ImportTask.objects.none()
-        else:
-            return ImportTask.objects.filter(
-                        user=self.request.user).order_by('date_created')
-
-    def create(self, request, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            raise exceptions.NotAuthenticated()
-        itask_data = {
-            'library': request.POST.get('library') not in ['false', False],
-            # NOTE: 'filename' here comes from 'name' (!) in the POST data
-            'filename': request.POST.get('name', None),
-            'destination': request.POST.get('destination', None),
-        }
-        if 'base64Encoded' in request.POST:
-            encoded_str = request.POST['base64Encoded']
-            encoded_substr = encoded_str[encoded_str.index('base64') + 7:]
-            itask_data['base64Encoded'] = encoded_substr
-        elif 'file' in request.data:
-            encoded_xls = base64.b64encode(request.data['file'].read())
-            itask_data['base64Encoded'] = encoded_xls
-            if 'filename' not in itask_data:
-                itask_data['filename'] = request.data['file'].name
-        elif 'url' in request.POST:
-            itask_data['single_xls_url'] = request.POST['url']
-        import_task = ImportTask.objects.create(user=request.user,
-                                                data=itask_data)
-        # Have Celery run the import in the background
-        import_in_background.delay(import_task_uid=import_task.uid)
-        return Response({
-            'uid': import_task.uid,
-            'url': reverse(
-                'importtask-detail',
-                kwargs={'uid': import_task.uid},
-                request=request),
-            'status': ImportTask.PROCESSING
-        }, status.HTTP_201_CREATED)
-
diff --git a/kpi/views/object_permission.py b/kpi/views/object_permission.py
deleted file mode 100644
index 9c1d84da88..0000000000
--- a/kpi/views/object_permission.py
+++ /dev/null
@@ -1,67 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.db import transaction
-from rest_framework import exceptions
-
-from kpi.constants import PERM_SHARE_SUBMISSIONS
-from kpi.filters import KpiAssignedObjectPermissionsFilter
-from kpi.models import ObjectPermission
-from kpi.serializers import ObjectPermissionSerializer
-from .no_update_model import NoUpdateModelViewSet
-
-
-class ObjectPermissionViewSet(NoUpdateModelViewSet):
-    queryset = ObjectPermission.objects.all()
-    serializer_class = ObjectPermissionSerializer
-    lookup_field = 'uid'
-    filter_backends = (KpiAssignedObjectPermissionsFilter, )
-
-    def _requesting_user_can_share(self, affected_object, codename):
-        r"""
-            Return `True` if `self.request.user` is allowed to grant and revoke
-            `codename` on `affected_object`. For `Collection`, this is always
-            the same as checking that `self.request.user` has the
-            `share_collection` permission on `affected_object`. For `Asset`,
-            the result is determined by either `share_asset` or
-            `share_submissions`, depending on the `codename`.
-            :type affected_object: :py:class:Asset or :py:class:Collection
-            :type codename: str
-            :rtype bool
-        """
-        model_name = affected_object._meta.model_name
-        if model_name == 'asset' and codename.endswith('_submissions'):
-            share_permission = PERM_SHARE_SUBMISSIONS
-        else:
-            share_permission = 'share_{}'.format(model_name)
-        return affected_object.has_perm(self.request.user, share_permission)
-
-    def perform_create(self, serializer):
-        # Make sure the requesting user has the share_ permission on
-        # the affected object
-        with transaction.atomic():
-            affected_object = serializer.validated_data['content_object']
-            codename = serializer.validated_data['permission'].codename
-            if not self._requesting_user_can_share(affected_object, codename):
-                raise exceptions.PermissionDenied()
-            serializer.save()
-
-    def perform_destroy(self, instance):
-        # Only directly-applied permissions may be modified; forbid deleting
-        # permissions inherited from ancestors
-        if instance.inherited:
-            raise exceptions.MethodNotAllowed(
-                self.request.method,
-                detail='Cannot delete inherited permissions.'
-            )
-        # Make sure the requesting user has the share_ permission on
-        # the affected object
-        with transaction.atomic():
-            affected_object = instance.content_object
-            codename = instance.permission.codename
-            if not self._requesting_user_can_share(affected_object, codename):
-                raise exceptions.PermissionDenied()
-            instance.content_object.remove_perm(
-                instance.user,
-                instance.permission.codename
-            )
diff --git a/kpi/views/one_time_authentication_key.py b/kpi/views/one_time_authentication_key.py
deleted file mode 100644
index fda2304504..0000000000
--- a/kpi/views/one_time_authentication_key.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from rest_framework import exceptions
-from rest_framework import mixins
-from rest_framework import viewsets
-
-from kpi.models import AuthorizedApplication, OneTimeAuthenticationKey
-from kpi.models.authorized_application import ApplicationTokenAuthentication
-from kpi.serializers import OneTimeAuthenticationKeySerializer
-
-
-class OneTimeAuthenticationKeyViewSet(
-        mixins.CreateModelMixin,
-        viewsets.GenericViewSet
-):
-    authentication_classes = [ApplicationTokenAuthentication]
-    queryset = OneTimeAuthenticationKey.objects.none()
-    serializer_class = OneTimeAuthenticationKeySerializer
-
-    def create(self, request, *args, **kwargs):
-        if type(request.auth) is not AuthorizedApplication:
-            # Only specially-authorized applications are allowed to create
-            # one-time authentication keys via this endpoint
-            raise exceptions.PermissionDenied()
-        return super(OneTimeAuthenticationKeyViewSet, self).create(
-            request, *args, **kwargs)
diff --git a/kpi/views/sitewide_message.py b/kpi/views/sitewide_message.py
deleted file mode 100644
index d9e177dd26..0000000000
--- a/kpi/views/sitewide_message.py
+++ /dev/null
@@ -1,12 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from rest_framework import viewsets
-
-from hub.models import SitewideMessage
-from kpi.serializers import SitewideMessageSerializer
-
-
-class SitewideMessageViewSet(viewsets.ModelViewSet):
-    queryset = SitewideMessage.objects.all()
-    serializer_class = SitewideMessageSerializer
diff --git a/kpi/views/submission.py b/kpi/views/submission.py
deleted file mode 100644
index dbbf4bd02b..0000000000
--- a/kpi/views/submission.py
+++ /dev/null
@@ -1,239 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.http import Http404
-from django.shortcuts import get_object_or_404
-from django.utils.translation import ugettext_lazy as _
-from rest_framework import renderers, viewsets
-from rest_framework.decorators import detail_route, list_route
-from rest_framework.response import Response
-from rest_framework_extensions.mixins import NestedViewSetMixin
-
-from kpi.models import Asset
-from kpi.permissions import SubmissionPermission
-from kpi.renderers import SubmissionXMLRenderer
-
-
-class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet):
-    """
-    ## List of submissions for a specific asset
-
-    
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters diff --git a/kpi/views/tag.py b/kpi/views/tag.py deleted file mode 100644 index 9c817f9cdf..0000000000 --- a/kpi/views/tag.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from itertools import chain - -from django.db.models import Q -from rest_framework import viewsets -from taggit.models import Tag - -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION -from kpi.filters import SearchFilter -from kpi.models import Asset, Collection -from kpi.models.object_permission import get_anonymous_user, get_objects_for_user -from kpi.serializers import TagSerializer, TagListSerializer - - -class TagViewSet(viewsets.ReadOnlyModelViewSet): - queryset = Tag.objects.all() - serializer_class = TagSerializer - lookup_field = 'taguid__uid' - filter_backends = (SearchFilter,) - - def get_queryset(self, *args, **kwargs): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - - def _get_tags_on_items(content_type_name, avail_items): - """ - return all ids of tags which are tagged to items of the given - content_type - """ - same_content_type = Q( - taggit_taggeditem_items__content_type__model=content_type_name) - same_id = Q( - taggit_taggeditem_items__object_id__in=avail_items. - values_list('id')) - return Tag.objects.filter(same_content_type & same_id).distinct().\ - values_list('id', flat=True) - - accessible_collections = get_objects_for_user( - user, PERM_VIEW_COLLECTION, Collection).only('pk') - accessible_assets = get_objects_for_user( - user, PERM_VIEW_ASSET, Asset).only('pk') - all_tag_ids = list(chain( - _get_tags_on_items('collection', accessible_collections), - _get_tags_on_items('asset', accessible_assets), - )) - - return Tag.objects.filter(id__in=all_tag_ids).distinct() - - def get_serializer_class(self): - if self.action == 'list': - return TagListSerializer - else: - return TagSerializer diff --git a/kpi/views/user.py b/kpi/views/user.py deleted file mode 100644 index 679d16ff61..0000000000 --- a/kpi/views/user.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from django.contrib.auth.models import User -from rest_framework import exceptions, mixins, viewsets - -from kpi.models.authorized_application import ApplicationTokenAuthentication -from kpi.serializers import UserSerializer - - -class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin): - """ - This viewset provides only the `detail` action; `list` is *not* provided to - avoid disclosing every username in the database - """ - queryset = User.objects.all() - serializer_class = UserSerializer - lookup_field = 'username' - - def __init__(self, *args, **kwargs): - super(UserViewSet, self).__init__(*args, **kwargs) - self.authentication_classes += [ApplicationTokenAuthentication] - - def list(self, request, *args, **kwargs): - raise exceptions.PermissionDenied() diff --git a/kpi/views/user_collection_subscription.py b/kpi/views/user_collection_subscription.py deleted file mode 100644 index 2d7052d23c..0000000000 --- a/kpi/views/user_collection_subscription.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from rest_framework import viewsets -from kpi.models import UserCollectionSubscription - -from kpi.models.object_permission import get_anonymous_user -from kpi.serializers import UserCollectionSubscriptionSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) diff --git a/kpi/views/v1/__init__.py b/kpi/views/v1/__init__.py index 6550b8d83c..773991fb9a 100644 --- a/kpi/views/v1/__init__.py +++ b/kpi/views/v1/__init__.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from .asset import AssetViewSet from .asset_file import AssetFileViewSet @@ -14,3 +15,7 @@ from .tag import TagViewSet from .user import UserViewSet from .user_collection_subscription import UserCollectionSubscriptionViewSet +======= +# -*- coding: utf-8 -*- +from __future__ import absolute_import +>>>>>>> Moved current views and serializers under 'v1' parent folder diff --git a/kpi/views/v1/asset.py b/kpi/views/v1/asset.py index 8d07f229bc..7e59a9b2d1 100644 --- a/kpi/views/v1/asset.py +++ b/kpi/views/v1/asset.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD # coding: utf-8 from django.shortcuts import get_object_or_404 from rest_framework import exceptions, renderers, status, viewsets @@ -17,6 +18,42 @@ class AssetViewSet(AssetViewSetV2): **Please upgrade to latest release `/api/v2/assets/`** +======= +# -*- coding: utf-8 -*- +from __future__ import unicode_literals, absolute_import + +import copy +import json +from hashlib import md5 + +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from rest_framework import exceptions, renderers, status, viewsets +from rest_framework.decorators import detail_route, list_route +from rest_framework.response import Response +from rest_framework_extensions.mixins import NestedViewSetMixin + +from kpi.constants import ASSET_TYPES, ASSET_TYPE_ARG_NAME, ASSET_TYPE_SURVEY, \ + ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ + CLONE_FROM_VERSION_ID_ARG_NAME, PERM_SHARE_ASSET, PERM_VIEW_ASSET +from kpi.deployment_backends.backends import DEPLOYMENT_BACKENDS +from kpi.exceptions import BadAssetTypeException +from kpi.filters import KpiObjectPermissionsFilter, SearchFilter +from kpi.highlighters import highlight_xform +from kpi.models import Asset +from kpi.models.object_permission import get_anonymous_user, get_objects_for_user +from kpi.permissions import IsOwnerOrReadOnly, PostMappedToChangePermission, \ + get_perm_name +from kpi.renderers import AssetJsonRenderer, SSJsonRenderer, XFormRenderer, \ + XlsRenderer +from kpi.serializers import AssetListSerializer, AssetSerializer, DeploymentSerializer +from kpi.utils.kobo_to_xlsform import to_xlsform_structure +from kpi.utils.ss_structure_to_mdtable import ss_structure_to_mdtable + + +class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ +>>>>>>> Moved current views and serializers under 'v1' parent folder * Assign a asset to a collection partially implemented * Run a partial update of a asset TODO @@ -35,7 +72,11 @@ class AssetViewSet(AssetViewSetV2): > > curl -X GET https://[kpi-url]/assets/ +<<<<<<< HEAD Get a hash of all `version_id`s of assets. +======= + Get an hash of all `version_id`s of assets. +>>>>>>> Moved current views and serializers under 'v1' parent folder Useful to detect any changes in assets with only one call to `API`
@@ -156,16 +197,320 @@ class AssetViewSet(AssetViewSetV2):
     ### CURRENT ENDPOINT
     """
 
+<<<<<<< HEAD
+=======
+    # Filtering handled by KpiObjectPermissionsFilter.filter_queryset()
+    queryset = Asset.objects.all()
+
+    serializer_class = AssetSerializer
+    lookup_field = 'uid'
+    permission_classes = (IsOwnerOrReadOnly,)
+    filter_backends = (KpiObjectPermissionsFilter, SearchFilter)
+
+    renderer_classes = (renderers.BrowsableAPIRenderer,
+                        AssetJsonRenderer,
+                        SSJsonRenderer,
+                        XFormRenderer,
+                        XlsRenderer,
+                        )
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
     def get_serializer_class(self):
         if self.action == 'list':
             return AssetListSerializer
         else:
             return AssetSerializer
 
+<<<<<<< HEAD
     def get_serializer_context(self):
         return super(AssetViewSetV2, self).get_serializer_context()
 
     @action(detail=True, methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+=======
+    def get_queryset(self, *args, **kwargs):
+        queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs)
+        if self.action == 'list':
+            return queryset.model.optimize_queryset_for_list(queryset)
+        else:
+            # This is called to retrieve an individual record. How much do we
+            # have to care about optimizations for that?
+            return queryset
+
+    def _get_clone_serializer(self, current_asset=None):
+        """
+        Gets the serializer from cloned object
+        :param current_asset: Asset. Asset to be updated.
+        :return: AssetSerializer
+        """
+        original_uid = self.request.data[CLONE_ARG_NAME]
+        original_asset = get_object_or_404(Asset, uid=original_uid)
+        if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data:
+            original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME)
+            source_version = get_object_or_404(
+                original_asset.asset_versions, uid=original_version_id)
+        else:
+            source_version = original_asset.asset_versions.first()
+
+        view_perm = get_perm_name('view', original_asset)
+        if not self.request.user.has_perm(view_perm, original_asset):
+            raise Http404
+
+        partial_update = isinstance(current_asset, Asset)
+        cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update)
+        if partial_update:
+            return self.get_serializer(current_asset, data=cloned_data, partial=True)
+        else:
+            return self.get_serializer(data=cloned_data)
+
+    def _prepare_cloned_data(self, original_asset, source_version, partial_update):
+        """
+        Some business rules must be applied when cloning an asset to another with a different type.
+        It prepares the data to be cloned accordingly.
+
+        It raises an exception if source and destination are not compatible for cloning.
+
+        :param original_asset: Asset
+        :param source_version: AssetVersion
+        :param partial_update: Boolean
+        :return: dict
+        """
+        if self._validate_destination_type(original_asset):
+            # `to_clone_dict()` returns only `name`, `content`, `asset_type`,
+            # and `tag_string`
+            cloned_data = original_asset.to_clone_dict(version=source_version)
+
+            # Allow the user's request data to override `cloned_data`
+            cloned_data.update(self.request.data.items())
+
+            if partial_update:
+                # Because we're updating an asset from another which can have another type,
+                # we need to remove `asset_type` from clone data to ensure it's not updated
+                # when serializer is initialized.
+                cloned_data.pop("asset_type", None)
+            else:
+                # Change asset_type if needed.
+                cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type)
+
+            cloned_asset_type = cloned_data.get("asset_type")
+            # Settings are: Country, Description, Sector and Share-metadata
+            # Copy settings only when original_asset is `survey` or `template`
+            # and `asset_type` property of `cloned_data` is `survey` or `template`
+            # or None (partial_update)
+            if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \
+                    original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]:
+
+                settings = original_asset.settings.copy()
+                settings.pop("share-metadata", None)
+
+                cloned_data_settings = cloned_data.get("settings", {})
+
+                # Depending of the client payload. settings can be JSON or string.
+                # if it's a string. Let's load it to be able to merge it.
+                if not isinstance(cloned_data_settings, dict):
+                    cloned_data_settings = json.loads(cloned_data_settings)
+
+                settings.update(cloned_data_settings)
+                cloned_data['settings'] = json.dumps(settings)
+
+            # until we get content passed as a dict, transform the content obj to a str
+            # TODO, verify whether `Asset.content.settings.id_string` should be cleared out.
+            cloned_data["content"] = json.dumps(cloned_data.get("content"))
+            return cloned_data
+        else:
+            raise BadAssetTypeException("Destination type is not compatible with source type")
+
+    def _validate_destination_type(self, original_asset_):
+        """
+        Validates if destination asset can be cloned from source asset.
+        :param original_asset_ Asset: Source
+        :return: Boolean
+        """
+        is_valid = True
+
+        if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data:
+            destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME)
+            if destination_type in dict(ASSET_TYPES).values():
+                source_type = original_asset_.asset_type
+                is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type)
+            else:
+                is_valid = False
+
+        return is_valid
+
+    def create(self, request, *args, **kwargs):
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer()
+        else:
+            serializer = self.get_serializer(data=request.data)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_create(serializer)
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_201_CREATED,
+                        headers=headers)
+
+    @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer])
+    def hash(self, request):
+        """
+        Creates an hash of `version_id` of all accessible assets by the user.
+        Useful to detect changes between each request.
+
+        :param request:
+        :return: JSON
+        """
+        user = self.request.user
+        if user.is_anonymous():
+            raise exceptions.NotAuthenticated()
+        else:
+            accessible_assets = get_objects_for_user(
+                user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY) \
+                .order_by("uid")
+
+            assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None]
+            # Sort alphabetically
+            assets_version_ids.sort()
+
+            if len(assets_version_ids) > 0:
+                hash = md5("".join(assets_version_ids)).hexdigest()
+            else:
+                hash = ""
+
+            return Response({
+                "hash": hash
+            })
+
+    @detail_route(renderer_classes=[renderers.JSONRenderer])
+    def content(self, request, uid):
+        asset = self.get_object()
+        return Response({
+            'kind': 'asset.content',
+            'uid': asset.uid,
+            'data': asset.to_ss_structure(),
+        })
+
+    @detail_route(renderer_classes=[renderers.JSONRenderer])
+    def valid_content(self, request, uid):
+        asset = self.get_object()
+        return Response({
+            'kind': 'asset.valid_content',
+            'uid': asset.uid,
+            'data': to_xlsform_structure(asset.content),
+        })
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def koboform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        return Response({'asset': asset, }, template_name='koboform.html')
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def table_view(self, request, *args, **kwargs):
+        sa = self.get_object()
+        md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content())
+        return Response('\n'
+                        '
' + md_table.strip())
+
+    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
+    def xls(self, request, *args, **kwargs):
+        return self.table_view(self, request, *args, **kwargs)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        asset = self.get_object()
+        export = asset._snapshot(regenerate=True)
+        # TODO-- forward to AssetSnapshotViewset.xform
+        response_data = copy.copy(export.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if export.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(
+        methods=['get', 'post', 'patch'],
+        permission_classes=[PostMappedToChangePermission]
+    )
+    def deployment(self, request, uid):
+        """
+        A GET request retrieves the existing deployment, if any.
+        A POST request creates a new deployment, but only if a deployment does
+            not exist already.
+        A PATCH request updates the `active` field of the existing deployment.
+        A PUT request overwrites the entire deployment, including the form
+            contents, but does not change the deployment's identifier
+        """
+        asset = self.get_object()
+        serializer_context = self.get_serializer_context()
+        serializer_context['asset'] = asset
+
+        # TODO: Require the client to provide a fully-qualified identifier,
+        # otherwise provide less kludgy solution
+        if 'identifier' not in request.data and 'id_string' in request.data:
+            id_string = request.data.pop('id_string')[0]
+            backend_name = request.data['backend']
+            try:
+                backend = DEPLOYMENT_BACKENDS[backend_name]
+            except KeyError:
+                raise KeyError(
+                    'cannot retrieve asset backend: "{}"'.format(backend_name))
+            request.data['identifier'] = backend.make_identifier(
+                request.user.username, id_string)
+
+        if request.method == 'GET':
+            if not asset.has_deployment:
+                raise Http404
+            else:
+                serializer = DeploymentSerializer(
+                    asset.deployment, context=serializer_context
+                )
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+        elif request.method == 'POST':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use PATCH to update an existing deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    data=request.data,
+                    context=serializer_context
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+        elif request.method == 'PATCH':
+            if not asset.can_be_deployed:
+                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
+                    asset.asset_type))
+            else:
+                if not asset.has_deployment:
+                    raise exceptions.MethodNotAllowed(
+                        method=request.method,
+                        detail='Use POST to create a new deployment'
+                    )
+                serializer = DeploymentSerializer(
+                    asset.deployment,
+                    data=request.data,
+                    context=serializer_context,
+                    partial=True
+                )
+                serializer.is_valid(raise_exception=True)
+                serializer.save()
+                # TODO: Understand why this 404s when `serializer.data` is not
+                # coerced to a dict
+                return Response(dict(serializer.data))
+
+    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+>>>>>>> Moved current views and serializers under 'v1' parent folder
     def permissions(self, request, uid):
         target_asset = self.get_object()
         source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
@@ -182,3 +527,54 @@ def permissions(self, request, uid):
             raise exceptions.PermissionDenied()
 
         return Response(response, status=http_status)
+<<<<<<< HEAD
+=======
+
+    def perform_create(self, serializer):
+        # Check if the user is anonymous. The
+        # django.contrib.auth.models.AnonymousUser object doesn't work for
+        # queries.
+        user = self.request.user
+        if user.is_anonymous():
+            user = get_anonymous_user()
+        serializer.save(owner=user)
+
+    def partial_update(self, request, *args, **kwargs):
+        instance = self.get_object()
+
+        if CLONE_ARG_NAME in request.data:
+            serializer = self._get_clone_serializer(instance)
+        else:
+            serializer = self.get_serializer(instance, data=request.data, partial=True)
+
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+        return Response(serializer.data)
+
+    def perform_destroy(self, instance):
+        if hasattr(instance, 'has_deployment') and instance.has_deployment:
+            instance.deployment.delete()
+        return super(AssetViewSet, self).perform_destroy(instance)
+
+    def finalize_response(self, request, response, *args, **kwargs):
+        """ Manipulate the headers as appropriate for the requested format.
+        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
+        """
+        # If the request fails at an early stage, e.g. the user has no
+        # model-level permissions, accepted_renderer won't be present.
+        if hasattr(request, 'accepted_renderer'):
+            # Check the class of the renderer instead of just looking at the
+            # format, because we don't want to set Content-Disposition:
+            # attachment on asset snapshot XML
+            if (isinstance(request.accepted_renderer, XlsRenderer) or
+                    isinstance(request.accepted_renderer, XFormRenderer)):
+                response[
+                    'Content-Disposition'
+                ] = 'attachment; filename={}.{}'.format(
+                    self.get_object().uid,
+                    request.accepted_renderer.format
+                )
+
+        return super(AssetViewSet, self).finalize_response(
+            request, response, *args, **kwargs)
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/asset_file.py b/kpi/views/v1/asset_file.py
index 361027c769..e480eed49d 100644
--- a/kpi/views/v1/asset_file.py
+++ b/kpi/views/v1/asset_file.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import AssetFileSerializer
 from kpi.views.v2.asset_file import AssetFileViewSet as AssetFileViewSetV2
@@ -11,3 +12,68 @@ class AssetFileViewSet(AssetFileViewSetV2):
     """
 
     serializer_class = AssetFileSerializer
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from private_storage.views import PrivateStorageDetailView
+from rest_framework import exceptions
+from rest_framework.decorators import detail_route
+from rest_framework_extensions.mixins import NestedViewSetMixin
+
+from kpi.constants import PERM_CHANGE_ASSET, PERM_VIEW_ASSET
+from kpi.filters import RelatedAssetPermissionsFilter
+from kpi.models import Asset, AssetFile
+from kpi.serializers import AssetFileSerializer
+
+from .no_update_model import NoUpdateModelViewSet
+
+
+class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet):
+    model = AssetFile
+    lookup_field = 'uid'
+    filter_backends = (RelatedAssetPermissionsFilter,)
+    serializer_class = AssetFileSerializer
+
+    def get_queryset(self):
+        _asset_uid = self.get_parents_query_dict()['asset']
+        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
+        return _queryset
+
+    def perform_create(self, serializer):
+        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
+        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
+            raise exceptions.PermissionDenied()
+        serializer.save(
+            asset=asset,
+            user=self.request.user
+        )
+
+    def perform_destroy(self, *args, **kwargs):
+        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
+        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
+            raise exceptions.PermissionDenied()
+        return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs)
+
+    class PrivateContentView(PrivateStorageDetailView):
+        model = AssetFile
+        model_file_field = 'content'
+
+        def can_access_file(self, private_file):
+            return private_file.request.user.has_perm(
+                PERM_VIEW_ASSET, private_file.parent_object.asset)
+
+    @detail_route(methods=['get'])
+    def content(self, *args, **kwargs):
+        view = self.PrivateContentView.as_view(
+            model=AssetFile,
+            slug_url_kwarg='uid',
+            slug_field='uid',
+            model_file_field='content'
+        )
+        af = self.get_object()
+        # TODO: simply redirect if external storage with expiring tokens (e.g.
+        # Amazon S3) is used?
+        #   return HttpResponseRedirect(af.content.url)
+        return view(self.request, uid=af.uid)
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/asset_snapshot.py b/kpi/views/v1/asset_snapshot.py
index 3ca0d8b331..598626a7c6 100644
--- a/kpi/views/v1/asset_snapshot.py
+++ b/kpi/views/v1/asset_snapshot.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import AssetSnapshotSerializer
 from kpi.views.v2.asset_snapshot import AssetSnapshotViewSet as AssetSnapshotViewSetV2
@@ -11,3 +12,85 @@ class AssetSnapshotViewSet(AssetSnapshotViewSetV2):
     """
 
     serializer_class = AssetSnapshotSerializer
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+import copy
+
+from django.http import HttpResponseRedirect
+from django.conf import settings
+from rest_framework import renderers
+from rest_framework.decorators import detail_route, list_route
+from rest_framework.response import Response
+from rest_framework.reverse import reverse
+
+from kpi.filters import RelatedAssetPermissionsFilter
+from kpi.highlighters import highlight_xform
+from kpi.models import AssetSnapshot
+from kpi.renderers import XMLRenderer
+from kpi.serializers import AssetSnapshotSerializer
+
+from .no_update_model import NoUpdateModelViewSet
+
+
+class AssetSnapshotViewSet(NoUpdateModelViewSet):
+    serializer_class = AssetSnapshotSerializer
+    lookup_field = 'uid'
+    queryset = AssetSnapshot.objects.all()
+
+    renderer_classes = NoUpdateModelViewSet.renderer_classes + [
+        XMLRenderer,
+    ]
+
+    def filter_queryset(self, queryset):
+        if (self.action == 'retrieve' and
+                self.request.accepted_renderer.format == 'xml'):
+            # The XML renderer is totally public and serves anyone, so
+            # /asset_snapshot/valid_uid.xml is world-readable, even though
+            # /asset_snapshot/valid_uid/ requires ownership. Return the
+            # queryset unfiltered
+            return queryset
+        else:
+            user = self.request.user
+            owned_snapshots = queryset.none()
+            if not user.is_anonymous():
+                owned_snapshots = queryset.filter(owner=user)
+            return owned_snapshots | RelatedAssetPermissionsFilter(
+                ).filter_queryset(self.request, queryset, view=self)
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def xform(self, request, *args, **kwargs):
+        """
+        This route will render the XForm into syntax-highlighted HTML.
+        It is useful for debugging pyxform transformations
+        """
+        snapshot = self.get_object()
+        response_data = copy.copy(snapshot.details)
+        options = {
+            'linenos': True,
+            'full': True,
+        }
+        if snapshot.xml != '':
+            response_data['highlighted_xform'] = highlight_xform(snapshot.xml,
+                                                                 **options)
+        return Response(response_data, template_name='highlighted_xform.html')
+
+    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
+    def preview(self, request, *args, **kwargs):
+        snapshot = self.get_object()
+        if snapshot.details.get('status') == 'success':
+            preview_url = "{}{}?form={}".format(
+                              settings.ENKETO_SERVER,
+                              settings.ENKETO_PREVIEW_URI,
+                              reverse(viewname='assetsnapshot-detail',
+                                      format='xml',
+                                      kwargs={'uid': snapshot.uid},
+                                      request=request,
+                                      ),
+                            )
+            return HttpResponseRedirect(preview_url)
+        else:
+            response_data = copy.copy(snapshot.details)
+            return Response(response_data, template_name='preview_error.html')
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/asset_version.py b/kpi/views/v1/asset_version.py
index 8886b3193c..ee6fc35b18 100644
--- a/kpi/views/v1/asset_version.py
+++ b/kpi/views/v1/asset_version.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import AssetVersionListSerializer, AssetVersionSerializer
 from kpi.views.v2.asset_version import \
@@ -10,9 +11,45 @@ class AssetVersionViewSet(AssetVersionViewSetV2):
 
     **Please upgrade to latest release `/api/v2/assets/{uid}/versions`**
     """
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from rest_framework import viewsets
+from rest_framework_extensions.mixins import NestedViewSetMixin
+from kpi.filters import AssetOwnerFilterBackend
+from kpi.models import AssetVersion
+from kpi.serializers import AssetVersionListSerializer, AssetVersionSerializer
+
+
+class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet):
+    model = AssetVersion
+    lookup_field = 'uid'
+    filter_backends = (
+            AssetOwnerFilterBackend,
+        )
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 
     def get_serializer_class(self):
         if self.action == 'list':
             return AssetVersionListSerializer
         else:
             return AssetVersionSerializer
+<<<<<<< HEAD
+=======
+
+    def get_queryset(self):
+        _asset_uid = self.get_parents_query_dict()['asset']
+        _deployed = self.request.query_params.get('deployed', None)
+        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
+        if _deployed is not None:
+            _queryset = _queryset.filter(deployed=_deployed)
+        if self.action == 'list':
+            # Save time by only retrieving fields from the DB that the
+            # serializer will use
+            _queryset = _queryset.only(
+                'uid', 'deployed', 'date_modified', 'asset_id')
+        # `AssetVersionListSerializer.get_url()` asks for the asset UID
+        _queryset = _queryset.select_related('asset__uid')
+        return _queryset
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/authorized_application_user.py b/kpi/views/v1/authorized_application_user.py
index d512b25403..71da87fd05 100644
--- a/kpi/views/v1/authorized_application_user.py
+++ b/kpi/views/v1/authorized_application_user.py
@@ -1,7 +1,15 @@
+<<<<<<< HEAD
 # coding: utf-8
 from django.contrib.auth.models import User
 from rest_framework import viewsets, mixins, exceptions
 
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from django.contrib.auth.models import User
+from rest_framework import viewsets, mixins, exceptions
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 from kpi.models import AuthorizedApplication
 from kpi.models.authorized_application import ApplicationTokenAuthentication
 from kpi.serializers import CreateUserSerializer
@@ -19,4 +27,9 @@ def create(self, request, *args, **kwargs):
             # Only specially-authorized applications are allowed to create
             # users via this endpoint
             raise exceptions.PermissionDenied()
+<<<<<<< HEAD
         return super().create(request, *args, **kwargs)
+=======
+        return super(AuthorizedApplicationUserViewSet, self).create(
+            request, *args, **kwargs)
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/collection.py b/kpi/views/v1/collection.py
index 3b6efab789..4a6fdff01a 100644
--- a/kpi/views/v1/collection.py
+++ b/kpi/views/v1/collection.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import CollectionSerializer, CollectionListSerializer
 from kpi.views.v2.collection import CollectionViewSet as CollectionViewSetV2
@@ -9,6 +10,105 @@ class CollectionViewSet(CollectionViewSetV2):
 
     **Please upgrade to latest release `/api/v2/collections/`**
     """
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from django.forms import model_to_dict
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from rest_framework import viewsets, status, exceptions
+from rest_framework.response import Response
+from kpi.filters import KpiObjectPermissionsFilter, SearchFilter
+from kpi.models import Collection
+from kpi.model_utils import disable_auto_field_update
+from kpi.permissions import IsOwnerOrReadOnly, get_perm_name
+from kpi.serializers import CollectionSerializer, CollectionListSerializer
+from kpi.constants import CLONE_ARG_NAME, COLLECTION_CLONE_FIELDS
+
+
+class CollectionViewSet(viewsets.ModelViewSet):
+    # Filtering handled by KpiObjectPermissionsFilter.filter_queryset()
+    queryset = Collection.objects.select_related(
+        'owner', 'parent'
+    ).prefetch_related(
+        'permissions',
+        'permissions__permission',
+        'permissions__user',
+        'permissions__content_object',
+        'usercollectionsubscription_set',
+    ).all().order_by('-date_modified')
+    serializer_class = CollectionSerializer
+    permission_classes = (IsOwnerOrReadOnly,)
+    filter_backends = (KpiObjectPermissionsFilter, SearchFilter)
+    lookup_field = 'uid'
+
+    def _clone(self):
+        # Clone an existing collection.
+        original_uid = self.request.data[CLONE_ARG_NAME]
+        original_collection = get_object_or_404(Collection, uid=original_uid)
+        view_perm = get_perm_name('view', original_collection)
+        if not self.request.user.has_perm(view_perm, original_collection):
+            raise Http404
+        else:
+            # Copy the essential data from the original collection.
+            original_data= model_to_dict(original_collection)
+            cloned_data= {keep_field: original_data[keep_field]
+                          for keep_field in COLLECTION_CLONE_FIELDS}
+            if original_collection.tag_string:
+                cloned_data['tag_string']= original_collection.tag_string
+
+            # Pull any additionally provided parameters/overrides from the
+            # request.
+            for param in self.request.data:
+                cloned_data[param] = self.request.data[param]
+            serializer = self.get_serializer(data=cloned_data)
+            serializer.is_valid(raise_exception=True)
+            self.perform_create(serializer)
+
+            headers = self.get_success_headers(serializer.data)
+            return Response(serializer.data, status=status.HTTP_201_CREATED,
+                            headers=headers)
+
+    def create(self, request, *args, **kwargs):
+        if CLONE_ARG_NAME not in request.data:
+            return super(CollectionViewSet, self).create(request, *args,
+                                                         **kwargs)
+        else:
+            return self._clone()
+
+    def perform_create(self, serializer):
+        serializer.save(owner=self.request.user)
+
+    def perform_update(self, serializer, *args, **kwargs):
+        """ Only the owner is allowed to change `discoverable_when_public` """
+        original_collection = self.get_object()
+        if (self.request.user != original_collection.owner and
+                'discoverable_when_public' in serializer.validated_data and
+                (serializer.validated_data['discoverable_when_public'] !=
+                    original_collection.discoverable_when_public)
+        ):
+            raise exceptions.PermissionDenied()
+
+        # Some fields shouldn't affect the modification date
+        FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set((
+            'discoverable_when_public',
+        ))
+        changed_fields = set()
+        for k, v in serializer.validated_data.iteritems():
+            if getattr(original_collection, k) != v:
+                changed_fields.add(k)
+        if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE):
+            with disable_auto_field_update(Collection, 'date_modified'):
+                return super(CollectionViewSet, self).perform_update(
+                    serializer, *args, **kwargs)
+
+        return super(CollectionViewSet, self).perform_update(
+                serializer, *args, **kwargs)
+
+    def perform_destroy(self, instance):
+        instance.delete_with_deferred_indexing()
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 
     def get_serializer_class(self):
         if self.action == 'list':
diff --git a/kpi/views/current_user.py b/kpi/views/v1/current_user.py
similarity index 100%
rename from kpi/views/current_user.py
rename to kpi/views/v1/current_user.py
diff --git a/kpi/views/environment.py b/kpi/views/v1/environment.py
similarity index 100%
rename from kpi/views/environment.py
rename to kpi/views/v1/environment.py
diff --git a/kpi/views/v1/export_task.py b/kpi/views/v1/export_task.py
index f50219d4c7..46580b8768 100644
--- a/kpi/views/v1/export_task.py
+++ b/kpi/views/v1/export_task.py
@@ -1,4 +1,10 @@
+<<<<<<< HEAD
 # coding: utf-8
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 from rest_framework import status, exceptions
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
@@ -8,7 +14,11 @@
 from kpi.model_utils import remove_string_prefix
 from kpi.serializers import ExportTaskSerializer
 from kpi.tasks import export_in_background
+<<<<<<< HEAD
 from kpi.views.no_update_model import NoUpdateModelViewSet
+=======
+from .no_update_model import NoUpdateModelViewSet
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 
 
 class ExportTaskViewSet(NoUpdateModelViewSet):
@@ -17,7 +27,11 @@ class ExportTaskViewSet(NoUpdateModelViewSet):
     lookup_field = 'uid'
 
     def get_queryset(self, *args, **kwargs):
+<<<<<<< HEAD
         if self.request.user.is_anonymous:
+=======
+        if self.request.user.is_anonymous():
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             return ExportTask.objects.none()
 
         queryset = ExportTask.objects.filter(
@@ -47,7 +61,11 @@ def get_queryset(self, *args, **kwargs):
         return queryset
 
     def create(self, request, *args, **kwargs):
+<<<<<<< HEAD
         if self.request.user.is_anonymous:
+=======
+        if self.request.user.is_anonymous():
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             raise exceptions.NotAuthenticated()
 
         # Read valid options from POST data
diff --git a/kpi/views/hook_signal.py b/kpi/views/v1/hook_signal.py
similarity index 100%
rename from kpi/views/hook_signal.py
rename to kpi/views/v1/hook_signal.py
diff --git a/kpi/views/v1/import_task.py b/kpi/views/v1/import_task.py
index 7532dace2f..af5ed1381e 100644
--- a/kpi/views/v1/import_task.py
+++ b/kpi/views/v1/import_task.py
@@ -1,4 +1,10 @@
+<<<<<<< HEAD
 # coding: utf-8
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 import base64
 
 from rest_framework import exceptions, status, viewsets
@@ -8,7 +14,10 @@
 from kpi.models import ImportTask
 from kpi.serializers import ImportTaskListSerializer, ImportTaskSerializer
 from kpi.tasks import import_in_background
+<<<<<<< HEAD
 from kpi.utils.strings import to_str
+=======
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 
 
 class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet):
@@ -23,14 +32,22 @@ def get_serializer_class(self):
             return ImportTaskSerializer
 
     def get_queryset(self, *args, **kwargs):
+<<<<<<< HEAD
         if self.request.user.is_anonymous:
+=======
+        if self.request.user.is_anonymous():
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             return ImportTask.objects.none()
         else:
             return ImportTask.objects.filter(
                         user=self.request.user).order_by('date_created')
 
     def create(self, request, *args, **kwargs):
+<<<<<<< HEAD
         if self.request.user.is_anonymous:
+=======
+        if self.request.user.is_anonymous():
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             raise exceptions.NotAuthenticated()
         itask_data = {
             'library': request.POST.get('library') not in ['false', False],
@@ -43,7 +60,11 @@ def create(self, request, *args, **kwargs):
             encoded_substr = encoded_str[encoded_str.index('base64') + 7:]
             itask_data['base64Encoded'] = encoded_substr
         elif 'file' in request.data:
+<<<<<<< HEAD
             encoded_xls = to_str(base64.b64encode(request.data['file'].read()))
+=======
+            encoded_xls = base64.b64encode(request.data['file'].read())
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             itask_data['base64Encoded'] = encoded_xls
             if 'filename' not in itask_data:
                 itask_data['filename'] = request.data['file'].name
diff --git a/kpi/views/no_update_model.py b/kpi/views/v1/no_update_model.py
similarity index 100%
rename from kpi/views/no_update_model.py
rename to kpi/views/v1/no_update_model.py
diff --git a/kpi/views/v1/object_permission.py b/kpi/views/v1/object_permission.py
index f753d0ae47..0de031bf2f 100644
--- a/kpi/views/v1/object_permission.py
+++ b/kpi/views/v1/object_permission.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from django.db import transaction
 from rest_framework import exceptions
@@ -7,6 +8,19 @@
 from kpi.serializers import ObjectPermissionSerializer
 from kpi.views.no_update_model import NoUpdateModelViewSet
 from kpi.utils.object_permission_helper import ObjectPermissionHelper
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from django.db import transaction
+from rest_framework import exceptions
+
+from kpi.constants import PERM_SHARE_SUBMISSIONS
+from kpi.filters import KpiAssignedObjectPermissionsFilter
+from kpi.models import ObjectPermission
+from kpi.serializers import ObjectPermissionSerializer
+from .no_update_model import NoUpdateModelViewSet
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 
 
 class ObjectPermissionViewSet(NoUpdateModelViewSet):
@@ -15,15 +29,41 @@ class ObjectPermissionViewSet(NoUpdateModelViewSet):
     lookup_field = 'uid'
     filter_backends = (KpiAssignedObjectPermissionsFilter, )
 
+<<<<<<< HEAD
+=======
+    def _requesting_user_can_share(self, affected_object, codename):
+        r"""
+            Return `True` if `self.request.user` is allowed to grant and revoke
+            `codename` on `affected_object`. For `Collection`, this is always
+            the same as checking that `self.request.user` has the
+            `share_collection` permission on `affected_object`. For `Asset`,
+            the result is determined by either `share_asset` or
+            `share_submissions`, depending on the `codename`.
+            :type affected_object: :py:class:Asset or :py:class:Collection
+            :type codename: str
+            :rtype bool
+        """
+        model_name = affected_object._meta.model_name
+        if model_name == 'asset' and codename.endswith('_submissions'):
+            share_permission = PERM_SHARE_SUBMISSIONS
+        else:
+            share_permission = 'share_{}'.format(model_name)
+        return affected_object.has_perm(self.request.user, share_permission)
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
     def perform_create(self, serializer):
         # Make sure the requesting user has the share_ permission on
         # the affected object
         with transaction.atomic():
             affected_object = serializer.validated_data['content_object']
             codename = serializer.validated_data['permission'].codename
+<<<<<<< HEAD
             if not ObjectPermissionHelper.user_can_share(affected_object,
                                                          self.request.user,
                                                          codename):
+=======
+            if not self._requesting_user_can_share(affected_object, codename):
+>>>>>>> Moved current views and serializers under 'v1' parent folder
                 raise exceptions.PermissionDenied()
             serializer.save()
 
@@ -40,9 +80,13 @@ def perform_destroy(self, instance):
         with transaction.atomic():
             affected_object = instance.content_object
             codename = instance.permission.codename
+<<<<<<< HEAD
             if not ObjectPermissionHelper.user_can_share(affected_object,
                                                          self.request.user,
                                                          codename):
+=======
+            if not self._requesting_user_can_share(affected_object, codename):
+>>>>>>> Moved current views and serializers under 'v1' parent folder
                 raise exceptions.PermissionDenied()
             instance.content_object.remove_perm(
                 instance.user,
diff --git a/kpi/views/v1/one_time_authentication_key.py b/kpi/views/v1/one_time_authentication_key.py
index 32c7ef928a..b01c7ada4e 100644
--- a/kpi/views/v1/one_time_authentication_key.py
+++ b/kpi/views/v1/one_time_authentication_key.py
@@ -1,4 +1,10 @@
+<<<<<<< HEAD
 # coding: utf-8
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 from rest_framework import exceptions
 from rest_framework import mixins
 from rest_framework import viewsets
@@ -21,5 +27,9 @@ def create(self, request, *args, **kwargs):
             # Only specially-authorized applications are allowed to create
             # one-time authentication keys via this endpoint
             raise exceptions.PermissionDenied()
+<<<<<<< HEAD
         return super().create(
+=======
+        return super(OneTimeAuthenticationKeyViewSet, self).create(
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             request, *args, **kwargs)
diff --git a/kpi/views/v1/sitewide_message.py b/kpi/views/v1/sitewide_message.py
index 904342de4d..ac66f03c25 100644
--- a/kpi/views/v1/sitewide_message.py
+++ b/kpi/views/v1/sitewide_message.py
@@ -1,4 +1,10 @@
+<<<<<<< HEAD
 # coding: utf-8
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 from rest_framework import viewsets
 
 from hub.models import SitewideMessage
diff --git a/kpi/views/v1/submission.py b/kpi/views/v1/submission.py
index 7104fde3b5..64407b3f1a 100644
--- a/kpi/views/v1/submission.py
+++ b/kpi/views/v1/submission.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from rest_framework.response import Response
 
@@ -10,6 +11,26 @@ class SubmissionViewSet(DataViewSet):
 
     **Please upgrade to latest release `/api/v2/assets/{uid}/data/`**
 
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import renderers, viewsets
+from rest_framework.decorators import detail_route, list_route
+from rest_framework.response import Response
+from rest_framework_extensions.mixins import NestedViewSetMixin
+
+from kpi.models import Asset
+from kpi.permissions import SubmissionPermission
+from kpi.renderers import SubmissionXMLRenderer
+
+
+class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet):
+    """
+>>>>>>> Moved current views and serializers under 'v1' parent folder
     ## List of submissions for a specific asset
 
     
@@ -139,6 +160,7 @@ class SubmissionViewSet(DataViewSet):
 
     ### CURRENT ENDPOINT
     """
+<<<<<<< HEAD
 
     def list(self, request, *args, **kwargs):
         format_type = kwargs.get('format', request.GET.get('format', 'json'))
@@ -148,3 +170,97 @@ def list(self, request, *args, **kwargs):
                                                  format_type=format_type,
                                                  **filters)
         return Response(list(submissions))
+=======
+    parent_model = Asset
+    renderer_classes = (renderers.BrowsableAPIRenderer,
+                        renderers.JSONRenderer,
+                        SubmissionXMLRenderer
+                        )
+    permission_classes = (SubmissionPermission,)
+
+    def _get_asset(self):
+
+        if not hasattr(self, "_asset"):
+            asset_uid = self.get_parents_query_dict()['asset']
+            asset = get_object_or_404(self.parent_model, uid=asset_uid)
+            self._asset = asset
+
+        return self._asset
+
+    def _get_deployment(self):
+        """
+        Returns the deployment for the asset specified by the request
+        """
+        asset = self._get_asset()
+
+        if not asset.has_deployment:
+            raise serializers.ValidationError(
+                _('The specified asset has not been deployed'))
+        return asset.deployment
+
+    def destroy(self, request, *args, **kwargs):
+        deployment = self._get_deployment()
+        pk = kwargs.get("pk")
+        json_response = deployment.delete_submission(pk, user=request.user)
+        return Response(**json_response)
+
+    @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer])
+    def edit(self, request, pk, *args, **kwargs):
+        deployment = self._get_deployment()
+        json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET)
+        return Response(**json_response)
+
+    def list(self, request, *args, **kwargs):
+        format_type = kwargs.get("format", request.GET.get("format", "json"))
+        deployment = self._get_deployment()
+        filters = self._filter_mongo_query(request)
+        submissions = deployment.get_submissions(format_type=format_type, **filters)
+        return Response(list(submissions))
+
+    def retrieve(self, request, pk, *args, **kwargs):
+        format_type = kwargs.get("format", request.GET.get("format", "json"))
+        deployment = self._get_deployment()
+        filters = self._filter_mongo_query(request)
+        submission = deployment.get_submission(pk, format_type=format_type, **filters)
+        if not submission:
+            raise Http404
+        return Response(submission)
+
+    @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def validation_status(self, request, pk, *args, **kwargs):
+        deployment = self._get_deployment()
+        if request.method == "PATCH":
+            json_response = deployment.set_validate_status(pk, request.data, request.user)
+        else:
+            json_response = deployment.get_validate_status(pk, request.GET, request.user)
+
+        return Response(**json_response)
+
+    @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
+    def validation_statuses(self, request, *args, **kwargs):
+        deployment = self._get_deployment()
+        json_response = deployment.set_validate_statuses(request.data, request.user)
+
+        return Response(**json_response)
+
+    def _filter_mongo_query(self, request):
+        """
+        Build filters to pass to Mongo query.
+        Acts like Django `filter_backends`
+
+        :param request:
+        :return: dict
+        """
+        filters = {}
+        asset = self._get_asset()
+
+        if request.method == "GET":
+            filters = request.GET.dict()
+
+        submitted_by = asset.get_usernames_for_restricted_perm(request.user)
+
+        filters.update({
+            "submitted_by": submitted_by
+        })
+        return filters
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/tag.py b/kpi/views/v1/tag.py
index b096b7cca4..1794263584 100644
--- a/kpi/views/v1/tag.py
+++ b/kpi/views/v1/tag.py
@@ -1,4 +1,10 @@
+<<<<<<< HEAD
 # coding: utf-8
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 from itertools import chain
 
 from django.db.models import Q
@@ -23,7 +29,11 @@ def get_queryset(self, *args, **kwargs):
         # Check if the user is anonymous. The
         # django.contrib.auth.models.AnonymousUser object doesn't work for
         # queries.
+<<<<<<< HEAD
         if user.is_anonymous:
+=======
+        if user.is_anonymous():
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             user = get_anonymous_user()
 
         def _get_tags_on_items(content_type_name, avail_items):
diff --git a/kpi/views/token.py b/kpi/views/v1/token.py
similarity index 100%
rename from kpi/views/token.py
rename to kpi/views/v1/token.py
diff --git a/kpi/views/v1/user.py b/kpi/views/v1/user.py
index c989b2a902..a6f68f0c54 100644
--- a/kpi/views/v1/user.py
+++ b/kpi/views/v1/user.py
@@ -1,3 +1,4 @@
+<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import UserSerializer
 from kpi.views.v2.user import UserViewSet as UserViewSetV2
@@ -14,3 +15,30 @@ class UserViewSet(UserViewSetV2):
     """
 
     serializer_class = UserSerializer
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from django.contrib.auth.models import User
+from rest_framework import exceptions, mixins, viewsets
+
+from kpi.models.authorized_application import ApplicationTokenAuthentication
+from kpi.serializers import UserSerializer
+
+
+class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
+    """
+    This viewset provides only the `detail` action; `list` is *not* provided to
+    avoid disclosing every username in the database
+    """
+    queryset = User.objects.all()
+    serializer_class = UserSerializer
+    lookup_field = 'username'
+
+    def __init__(self, *args, **kwargs):
+        super(UserViewSet, self).__init__(*args, **kwargs)
+        self.authentication_classes += [ApplicationTokenAuthentication]
+
+    def list(self, request, *args, **kwargs):
+        raise exceptions.PermissionDenied()
+>>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/user_collection_subscription.py b/kpi/views/v1/user_collection_subscription.py
index 5733e8f9c1..fbfc5632c1 100644
--- a/kpi/views/v1/user_collection_subscription.py
+++ b/kpi/views/v1/user_collection_subscription.py
@@ -1,4 +1,10 @@
+<<<<<<< HEAD
 # coding: utf-8
+=======
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+>>>>>>> Moved current views and serializers under 'v1' parent folder
 from rest_framework import viewsets
 from kpi.models import UserCollectionSubscription
 
@@ -16,7 +22,11 @@ def get_queryset(self):
         # Check if the user is anonymous. The
         # django.contrib.auth.models.AnonymousUser object doesn't work for
         # queries.
+<<<<<<< HEAD
         if user.is_anonymous:
+=======
+        if user.is_anonymous():
+>>>>>>> Moved current views and serializers under 'v1' parent folder
             user = get_anonymous_user()
         criteria = {'user': user}
         if 'collection__uid' in self.request.query_params:

From 8f559eddfa1bd1113ca8769f807a7d2a31a591ad Mon Sep 17 00:00:00 2001
From: Olivier Leger 
Date: Thu, 2 May 2019 14:35:11 -0400
Subject: [PATCH 035/499] Refactored views structure to handle versions of api

---
 kpi/urls/__init__.py                  |   30 +-
 kpi/views/current_user.py             |   14 +
 kpi/views/{v1 => }/environment.py     |    0
 kpi/views/{v1 => }/no_update_model.py |    0
 kpi/views/{v1 => }/token.py           |    1 -
 kpi/views/v1/__init__.py              |    8 +-
 kpi/views/v1/asset_file.py            |   66 -
 kpi/views/v1/asset_snapshot.py        |   83 --
 kpi/views/v1/current_user.py          | 1648 -------------------------
 kpi/views/v1/export_task.py           |   19 +-
 kpi/views/v1/object_permission.py     |   42 -
 11 files changed, 23 insertions(+), 1888 deletions(-)
 create mode 100644 kpi/views/current_user.py
 rename kpi/views/{v1 => }/environment.py (100%)
 rename kpi/views/{v1 => }/no_update_model.py (100%)
 rename kpi/views/{v1 => }/token.py (99%)
 delete mode 100644 kpi/views/v1/current_user.py

diff --git a/kpi/urls/__init__.py b/kpi/urls/__init__.py
index 5072730991..fa22f9d3df 100644
--- a/kpi/urls/__init__.py
+++ b/kpi/urls/__init__.py
@@ -1,6 +1,6 @@
 # coding: utf-8
 import private_storage.urls
-<<<<<<< HEAD
+from django.conf import settings
 from django.contrib.auth import logout
 from django.urls import include, re_path, path
 from django.views.i18n import JavaScriptCatalog
@@ -9,14 +9,6 @@
 from hub.views import ExtraDetailRegistrationView
 from kobo.apps.superuser_stats.views import user_report, retrieve_user_report
 from kpi.forms import RegistrationForm
-=======
-
-from hub.models import ConfigurationFile
-from hub.views import ExtraDetailRegistrationView
-from hub.views import switch_builder
-from kpi.forms import RegistrationForm
-from kpi.views import CurrentUserViewSet, TokenView, EnvironmentView
->>>>>>> WIP - Use separate router,views,serializer for api v1 and v2
 from kpi.views import authorized_application_authenticate_user
 from kpi.views import home, one_time_login, browser_tests
 from kpi.views.environment import EnvironmentView
@@ -26,9 +18,6 @@
 from .router_api_v1 import router_api_v1
 from .router_api_v2 import router_api_v2, URL_NAMESPACE
 
-from .router_api_v1 import router_api_v1
-from .router_api_v2 import router_api_v2
-
 # TODO: Give other apps their own `urls.py` files instead of importing their
 # views directly! See
 # https://docs.djangoproject.com/en/1.8/intro/tutorial03/#namespacing-url-names
@@ -41,22 +30,11 @@
         'get': 'retrieve',
         'patch': 'partial_update',
     }), name='currentuser-detail'),
-<<<<<<< HEAD
-    re_path(r'^grant-default-model-level-perms$', CurrentUserViewSet.as_view({
-        'post': 'grant_default_model_level_perms',
-    }), name='currentuser-detail'),
     re_path(r'^', include(router_api_v1.urls)),
     re_path(r'^api/v2/', include((router_api_v2.urls, URL_NAMESPACE))),
     re_path(r'^api-auth/', include('rest_framework.urls',
                                    namespace='rest_framework')),
     re_path(r'^accounts/register/$', ExtraDetailRegistrationView.as_view(
-=======
-    url(r'^', include(router_api_v1.urls)),
-    url(r'^api/v2/', include(router_api_v2.urls, namespace='api_v2')),
-    url(r'^api-auth/', include('rest_framework.urls',
-                               namespace='rest_framework')),
-    url(r'^accounts/register/$', ExtraDetailRegistrationView.as_view(
->>>>>>> WIP - Use separate router,views,serializer for api v1 and v2
         form_class=RegistrationForm), name='registration_register'),
     re_path(r'^accounts/logout/', logout, {'next_page': '/'}),
     re_path(r'^accounts/', include('registration.backends.default.urls')),
@@ -81,3 +59,9 @@
     re_path(r'^superuser_stats/user_report/(?P[^/]+)$',
             retrieve_user_report),
 ]
+
+if settings.DEBUG and settings.ENV == 'dev':
+    import debug_toolbar
+    urlpatterns = [
+        path('__debug__/', include(debug_toolbar.urls)),
+    ] + urlpatterns
diff --git a/kpi/views/current_user.py b/kpi/views/current_user.py
new file mode 100644
index 0000000000..f540a68957
--- /dev/null
+++ b/kpi/views/current_user.py
@@ -0,0 +1,14 @@
+
+# coding: utf-8
+from django.contrib.auth.models import User
+from rest_framework import viewsets
+
+from kpi.serializers import CurrentUserSerializer
+
+
+class CurrentUserViewSet(viewsets.ModelViewSet):
+    queryset = User.objects.none()
+    serializer_class = CurrentUserSerializer
+
+    def get_object(self):
+        return self.request.user
diff --git a/kpi/views/v1/environment.py b/kpi/views/environment.py
similarity index 100%
rename from kpi/views/v1/environment.py
rename to kpi/views/environment.py
diff --git a/kpi/views/v1/no_update_model.py b/kpi/views/no_update_model.py
similarity index 100%
rename from kpi/views/v1/no_update_model.py
rename to kpi/views/no_update_model.py
diff --git a/kpi/views/v1/token.py b/kpi/views/token.py
similarity index 99%
rename from kpi/views/v1/token.py
rename to kpi/views/token.py
index 7a78cfa964..df028f4b69 100644
--- a/kpi/views/v1/token.py
+++ b/kpi/views/token.py
@@ -7,7 +7,6 @@
 from rest_framework.response import Response
 from rest_framework.views import APIView
 
-
 class TokenView(APIView):
     def _which_user(self, request):
         """
diff --git a/kpi/views/v1/__init__.py b/kpi/views/v1/__init__.py
index 773991fb9a..848e6509ae 100644
--- a/kpi/views/v1/__init__.py
+++ b/kpi/views/v1/__init__.py
@@ -1,11 +1,9 @@
-<<<<<<< HEAD
 # coding: utf-8
 from .asset import AssetViewSet
 from .asset_file import AssetFileViewSet
 from .asset_snapshot import AssetSnapshotViewSet
 from .asset_version import AssetVersionViewSet
 from .authorized_application_user import AuthorizedApplicationUserViewSet
-from .collection import CollectionViewSet
 from .export_task import ExportTaskViewSet
 from .import_task import ImportTaskViewSet
 from .object_permission import ObjectPermissionViewSet
@@ -14,8 +12,4 @@
 from .submission import SubmissionViewSet
 from .tag import TagViewSet
 from .user import UserViewSet
-from .user_collection_subscription import UserCollectionSubscriptionViewSet
-=======
-# -*- coding: utf-8 -*-
-from __future__ import absolute_import
->>>>>>> Moved current views and serializers under 'v1' parent folder
+from .user_asset_subscription import UserAssetSubscriptionViewSet
diff --git a/kpi/views/v1/asset_file.py b/kpi/views/v1/asset_file.py
index e480eed49d..361027c769 100644
--- a/kpi/views/v1/asset_file.py
+++ b/kpi/views/v1/asset_file.py
@@ -1,4 +1,3 @@
-<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import AssetFileSerializer
 from kpi.views.v2.asset_file import AssetFileViewSet as AssetFileViewSetV2
@@ -12,68 +11,3 @@ class AssetFileViewSet(AssetFileViewSetV2):
     """
 
     serializer_class = AssetFileSerializer
-=======
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from private_storage.views import PrivateStorageDetailView
-from rest_framework import exceptions
-from rest_framework.decorators import detail_route
-from rest_framework_extensions.mixins import NestedViewSetMixin
-
-from kpi.constants import PERM_CHANGE_ASSET, PERM_VIEW_ASSET
-from kpi.filters import RelatedAssetPermissionsFilter
-from kpi.models import Asset, AssetFile
-from kpi.serializers import AssetFileSerializer
-
-from .no_update_model import NoUpdateModelViewSet
-
-
-class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet):
-    model = AssetFile
-    lookup_field = 'uid'
-    filter_backends = (RelatedAssetPermissionsFilter,)
-    serializer_class = AssetFileSerializer
-
-    def get_queryset(self):
-        _asset_uid = self.get_parents_query_dict()['asset']
-        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
-        return _queryset
-
-    def perform_create(self, serializer):
-        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
-        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
-            raise exceptions.PermissionDenied()
-        serializer.save(
-            asset=asset,
-            user=self.request.user
-        )
-
-    def perform_destroy(self, *args, **kwargs):
-        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
-        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
-            raise exceptions.PermissionDenied()
-        return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs)
-
-    class PrivateContentView(PrivateStorageDetailView):
-        model = AssetFile
-        model_file_field = 'content'
-
-        def can_access_file(self, private_file):
-            return private_file.request.user.has_perm(
-                PERM_VIEW_ASSET, private_file.parent_object.asset)
-
-    @detail_route(methods=['get'])
-    def content(self, *args, **kwargs):
-        view = self.PrivateContentView.as_view(
-            model=AssetFile,
-            slug_url_kwarg='uid',
-            slug_field='uid',
-            model_file_field='content'
-        )
-        af = self.get_object()
-        # TODO: simply redirect if external storage with expiring tokens (e.g.
-        # Amazon S3) is used?
-        #   return HttpResponseRedirect(af.content.url)
-        return view(self.request, uid=af.uid)
->>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/asset_snapshot.py b/kpi/views/v1/asset_snapshot.py
index 598626a7c6..3ca0d8b331 100644
--- a/kpi/views/v1/asset_snapshot.py
+++ b/kpi/views/v1/asset_snapshot.py
@@ -1,4 +1,3 @@
-<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import AssetSnapshotSerializer
 from kpi.views.v2.asset_snapshot import AssetSnapshotViewSet as AssetSnapshotViewSetV2
@@ -12,85 +11,3 @@ class AssetSnapshotViewSet(AssetSnapshotViewSetV2):
     """
 
     serializer_class = AssetSnapshotSerializer
-=======
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-import copy
-
-from django.http import HttpResponseRedirect
-from django.conf import settings
-from rest_framework import renderers
-from rest_framework.decorators import detail_route, list_route
-from rest_framework.response import Response
-from rest_framework.reverse import reverse
-
-from kpi.filters import RelatedAssetPermissionsFilter
-from kpi.highlighters import highlight_xform
-from kpi.models import AssetSnapshot
-from kpi.renderers import XMLRenderer
-from kpi.serializers import AssetSnapshotSerializer
-
-from .no_update_model import NoUpdateModelViewSet
-
-
-class AssetSnapshotViewSet(NoUpdateModelViewSet):
-    serializer_class = AssetSnapshotSerializer
-    lookup_field = 'uid'
-    queryset = AssetSnapshot.objects.all()
-
-    renderer_classes = NoUpdateModelViewSet.renderer_classes + [
-        XMLRenderer,
-    ]
-
-    def filter_queryset(self, queryset):
-        if (self.action == 'retrieve' and
-                self.request.accepted_renderer.format == 'xml'):
-            # The XML renderer is totally public and serves anyone, so
-            # /asset_snapshot/valid_uid.xml is world-readable, even though
-            # /asset_snapshot/valid_uid/ requires ownership. Return the
-            # queryset unfiltered
-            return queryset
-        else:
-            user = self.request.user
-            owned_snapshots = queryset.none()
-            if not user.is_anonymous():
-                owned_snapshots = queryset.filter(owner=user)
-            return owned_snapshots | RelatedAssetPermissionsFilter(
-                ).filter_queryset(self.request, queryset, view=self)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        """
-        This route will render the XForm into syntax-highlighted HTML.
-        It is useful for debugging pyxform transformations
-        """
-        snapshot = self.get_object()
-        response_data = copy.copy(snapshot.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if snapshot.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(snapshot.xml,
-                                                                 **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def preview(self, request, *args, **kwargs):
-        snapshot = self.get_object()
-        if snapshot.details.get('status') == 'success':
-            preview_url = "{}{}?form={}".format(
-                              settings.ENKETO_SERVER,
-                              settings.ENKETO_PREVIEW_URI,
-                              reverse(viewname='assetsnapshot-detail',
-                                      format='xml',
-                                      kwargs={'uid': snapshot.uid},
-                                      request=request,
-                                      ),
-                            )
-            return HttpResponseRedirect(preview_url)
-        else:
-            response_data = copy.copy(snapshot.details)
-            return Response(response_data, template_name='preview_error.html')
->>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/current_user.py b/kpi/views/v1/current_user.py
deleted file mode 100644
index 2f48578ba4..0000000000
--- a/kpi/views/v1/current_user.py
+++ /dev/null
@@ -1,1648 +0,0 @@
-<<<<<<< HEAD
-# coding: utf-8
-from django.contrib.auth.models import User
-from rest_framework import viewsets
-
-from kpi.serializers import CurrentUserSerializer
-=======
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.contrib.auth.models import User
-<<<<<<< HEAD
-from django.contrib.auth.decorators import login_required
-from django.db import transaction
-from django.db.models import Q, Count
-from django.forms import model_to_dict
-from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
-from django.utils.http import is_safe_url
-from django.shortcuts import get_object_or_404, resolve_url
-from django.template.response import TemplateResponse
-from django.conf import settings
-from django.views.decorators.http import require_POST
-from django.views.decorators.csrf import csrf_exempt
-from django.utils.translation import ugettext_lazy as _
-from rest_framework import (
-    viewsets,
-    mixins,
-    renderers,
-    status,
-    exceptions,
-)
-from rest_framework.decorators import api_view
-from rest_framework.decorators import renderer_classes
-from rest_framework.decorators import detail_route, list_route
-from rest_framework.decorators import authentication_classes
-from rest_framework.parsers import MultiPartParser
-from rest_framework.response import Response
-from rest_framework.reverse import reverse
-from rest_framework.authtoken.models import Token
-from rest_framework.views import APIView
-from rest_framework_extensions.mixins import NestedViewSetMixin
-
-import constance
-from taggit.models import Tag
-from private_storage.views import PrivateStorageDetailView
-
-from .filters import KpiAssignedObjectPermissionsFilter
-from .filters import AssetOwnerFilterBackend
-from .filters import KpiObjectPermissionsFilter, RelatedAssetPermissionsFilter
-from .filters import SearchFilter
-from .highlighters import highlight_xform
-from hub.models import SitewideMessage
-from .models import (
-    Collection,
-    Asset,
-    AssetVersion,
-    AssetSnapshot,
-    AssetFile,
-    ImportTask,
-    ExportTask,
-    ObjectPermission,
-    AuthorizedApplication,
-    OneTimeAuthenticationKey,
-    UserCollectionSubscription,
-    )
-from .models.object_permission import get_anonymous_user, get_objects_for_user
-from .models.authorized_application import ApplicationTokenAuthentication
-from .models.import_export_task import _resolve_url_to_asset_or_collection
-from .model_utils import disable_auto_field_update, remove_string_prefix
-from .permissions import (
-    IsOwnerOrReadOnly,
-    PostMappedToChangePermission,
-    get_perm_name,
-    SubmissionPermission
-)
-from .renderers import (
-    AssetJsonRenderer,
-    SSJsonRenderer,
-    XFormRenderer,
-    XMLRenderer,
-    SubmissionXMLRenderer,
-    XlsRenderer,)
-from .serializers import (
-    AssetSerializer, AssetListSerializer,
-    AssetVersionListSerializer,
-    AssetVersionSerializer,
-    AssetFileSerializer,
-    AssetSnapshotSerializer,
-    SitewideMessageSerializer,
-    CollectionSerializer, CollectionListSerializer,
-    UserSerializer,
-    CurrentUserSerializer, CreateUserSerializer,
-    TagSerializer, TagListSerializer,
-    ImportTaskSerializer, ImportTaskListSerializer,
-    ExportTaskSerializer,
-    ObjectPermissionSerializer,
-    AuthorizedApplicationUserSerializer,
-    OneTimeAuthenticationKeySerializer,
-    DeploymentSerializer,
-    UserCollectionSubscriptionSerializer,)
-from .utils.gravatar_url import gravatar_url
-from .utils.kobo_to_xlsform import to_xlsform_structure
-from .utils.ss_structure_to_mdtable import ss_structure_to_mdtable
-from .tasks import import_in_background, export_in_background
-from .constants import CLONE_ARG_NAME, CLONE_FROM_VERSION_ID_ARG_NAME, \
-    COLLECTION_CLONE_FIELDS, ASSET_TYPE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \
-    ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY, ASSET_TYPES, \
-    PERM_VIEW_ASSET, PERM_SHARE_ASSET, PERM_CHANGE_ASSET, \
-    PERM_VIEW_COLLECTION, PERM_SHARE_SUBMISSIONS
-from .deployment_backends.backends import DEPLOYMENT_BACKENDS
-
-from kobo.apps.hook.utils import HookUtils
-from kpi.exceptions import BadAssetTypeException
-from kpi.utils.log import logging
-
-
-@login_required
-def home(request):
-    return TemplateResponse(request, "index.html")
-
-
-def browser_tests(request):
-    return TemplateResponse(request, "browser_tests.html")
-
-
-class NoUpdateModelViewSet(
-    mixins.CreateModelMixin,
-    mixins.RetrieveModelMixin,
-    mixins.DestroyModelMixin,
-    mixins.ListModelMixin,
-    viewsets.GenericViewSet
-):
-    '''
-    Inherit from everything that ModelViewSet does, except for
-    UpdateModelMixin.
-    '''
-    pass
-
-
-class ObjectPermissionViewSet(NoUpdateModelViewSet):
-    queryset = ObjectPermission.objects.all()
-    serializer_class = ObjectPermissionSerializer
-    lookup_field = 'uid'
-    filter_backends = (KpiAssignedObjectPermissionsFilter, )
-
-    def _requesting_user_can_share(self, affected_object, codename):
-        r"""
-            Return `True` if `self.request.user` is allowed to grant and revoke
-            `codename` on `affected_object`. For `Collection`, this is always
-            the same as checking that `self.request.user` has the
-            `share_collection` permission on `affected_object`. For `Asset`,
-            the result is determined by either `share_asset` or
-            `share_submissions`, depending on the `codename`.
-            :type affected_object: :py:class:Asset or :py:class:Collection
-            :type codename: str
-            :rtype bool
-        """
-        model_name = affected_object._meta.model_name
-        if model_name == 'asset' and codename.endswith('_submissions'):
-            share_permission = PERM_SHARE_SUBMISSIONS
-        else:
-            share_permission = 'share_{}'.format(model_name)
-        return affected_object.has_perm(self.request.user, share_permission)
-
-    def perform_create(self, serializer):
-        # Make sure the requesting user has the share_ permission on
-        # the affected object
-        with transaction.atomic():
-            affected_object = serializer.validated_data['content_object']
-            codename = serializer.validated_data['permission'].codename
-            if not self._requesting_user_can_share(affected_object, codename):
-                raise exceptions.PermissionDenied()
-            serializer.save()
-
-    def perform_destroy(self, instance):
-        # Only directly-applied permissions may be modified; forbid deleting
-        # permissions inherited from ancestors
-        if instance.inherited:
-            raise exceptions.MethodNotAllowed(
-                self.request.method,
-                detail='Cannot delete inherited permissions.'
-            )
-        # Make sure the requesting user has the share_ permission on
-        # the affected object
-        with transaction.atomic():
-            affected_object = instance.content_object
-            codename = instance.permission.codename
-            if not self._requesting_user_can_share(affected_object, codename):
-                raise exceptions.PermissionDenied()
-            instance.content_object.remove_perm(
-                instance.user,
-                instance.permission.codename
-            )
-
-
-class CollectionViewSet(viewsets.ModelViewSet):
-    # Filtering handled by KpiObjectPermissionsFilter.filter_queryset()
-    queryset = Collection.objects.select_related(
-        'owner', 'parent'
-    ).prefetch_related(
-        'permissions',
-        'permissions__permission',
-        'permissions__user',
-        'permissions__content_object',
-        'usercollectionsubscription_set',
-    ).all().order_by('-date_modified')
-    serializer_class = CollectionSerializer
-    permission_classes = (IsOwnerOrReadOnly,)
-    filter_backends = (KpiObjectPermissionsFilter, SearchFilter)
-    lookup_field = 'uid'
-
-    def _clone(self):
-        # Clone an existing collection.
-        original_uid = self.request.data[CLONE_ARG_NAME]
-        original_collection = get_object_or_404(Collection, uid=original_uid)
-        view_perm = get_perm_name('view', original_collection)
-        if not self.request.user.has_perm(view_perm, original_collection):
-            raise Http404
-        else:
-            # Copy the essential data from the original collection.
-            original_data= model_to_dict(original_collection)
-            cloned_data= {keep_field: original_data[keep_field]
-                          for keep_field in COLLECTION_CLONE_FIELDS}
-            if original_collection.tag_string:
-                cloned_data['tag_string']= original_collection.tag_string
-
-            # Pull any additionally provided parameters/overrides from the
-            # request.
-            for param in self.request.data:
-                cloned_data[param] = self.request.data[param]
-            serializer = self.get_serializer(data=cloned_data)
-            serializer.is_valid(raise_exception=True)
-            self.perform_create(serializer)
-
-            headers = self.get_success_headers(serializer.data)
-            return Response(serializer.data, status=status.HTTP_201_CREATED,
-                            headers=headers)
-
-    def create(self, request, *args, **kwargs):
-        if CLONE_ARG_NAME not in request.data:
-            return super(CollectionViewSet, self).create(request, *args,
-                                                         **kwargs)
-        else:
-            return self._clone()
-
-    def perform_create(self, serializer):
-        serializer.save(owner=self.request.user)
-
-    def perform_update(self, serializer, *args, **kwargs):
-        ''' Only the owner is allowed to change `discoverable_when_public` '''
-        original_collection = self.get_object()
-        if (self.request.user != original_collection.owner and
-                'discoverable_when_public' in serializer.validated_data and
-                (serializer.validated_data['discoverable_when_public'] !=
-                    original_collection.discoverable_when_public)
-        ):
-            raise exceptions.PermissionDenied()
-
-        # Some fields shouldn't affect the modification date
-        FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set((
-            'discoverable_when_public',
-        ))
-        changed_fields = set()
-        for k, v in serializer.validated_data.iteritems():
-            if getattr(original_collection, k) != v:
-                changed_fields.add(k)
-        if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE):
-            with disable_auto_field_update(Collection, 'date_modified'):
-                return super(CollectionViewSet, self).perform_update(
-                    serializer, *args, **kwargs)
-
-        return super(CollectionViewSet, self).perform_update(
-                serializer, *args, **kwargs)
-
-    def perform_destroy(self, instance):
-        instance.delete_with_deferred_indexing()
-
-    def get_serializer_class(self):
-        if self.action == 'list':
-            return CollectionListSerializer
-        else:
-            return CollectionSerializer
-
-
-class TagViewSet(viewsets.ReadOnlyModelViewSet):
-    queryset = Tag.objects.all()
-    serializer_class = TagSerializer
-    lookup_field = 'taguid__uid'
-    filter_backends = (SearchFilter,)
-
-    def get_queryset(self, *args, **kwargs):
-        user = self.request.user
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        if user.is_anonymous():
-            user = get_anonymous_user()
-
-        def _get_tags_on_items(content_type_name, avail_items):
-            '''
-            return all ids of tags which are tagged to items of the given
-            content_type
-            '''
-            same_content_type = Q(
-                taggit_taggeditem_items__content_type__model=content_type_name)
-            same_id = Q(
-                taggit_taggeditem_items__object_id__in=avail_items.
-                values_list('id'))
-            return Tag.objects.filter(same_content_type & same_id).distinct().\
-                values_list('id', flat=True)
-
-        accessible_collections = get_objects_for_user(
-            user, PERM_VIEW_COLLECTION, Collection).only('pk')
-        accessible_assets = get_objects_for_user(
-            user, PERM_VIEW_ASSET, Asset).only('pk')
-        all_tag_ids = list(chain(
-            _get_tags_on_items('collection', accessible_collections),
-            _get_tags_on_items('asset', accessible_assets),
-        ))
-
-        return Tag.objects.filter(id__in=all_tag_ids).distinct()
-
-    def get_serializer_class(self):
-        if self.action == 'list':
-            return TagListSerializer
-        else:
-            return TagSerializer
-
-
-class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
-    """
-    This viewset provides only the `detail` action; `list` is *not* provided to
-    avoid disclosing every username in the database
-    """
-    queryset = User.objects.all()
-    serializer_class = UserSerializer
-    lookup_field = 'username'
-
-    def __init__(self, *args, **kwargs):
-        super(UserViewSet, self).__init__(*args, **kwargs)
-        self.authentication_classes += [ApplicationTokenAuthentication]
-
-    def list(self, request, *args, **kwargs):
-        raise exceptions.PermissionDenied()
->>>>>>> WIP - splitted views.py in several files
-=======
-from rest_framework import viewsets
-from kpi.serializers import CurrentUserSerializer
->>>>>>> Removed useless imports and unrelated codes from views files
-
-
-class CurrentUserViewSet(viewsets.ModelViewSet):
-    queryset = User.objects.none()
-    serializer_class = CurrentUserSerializer
-
-    def get_object(self):
-        return self.request.user
-<<<<<<< HEAD
-<<<<<<< HEAD
-=======
-
-
-class AuthorizedApplicationUserViewSet(mixins.CreateModelMixin,
-                                       viewsets.GenericViewSet):
-    authentication_classes = [ApplicationTokenAuthentication]
-    queryset = User.objects.all()
-    serializer_class = CreateUserSerializer
-    lookup_field = 'username'
-    def create(self, request, *args, **kwargs):
-        if type(request.auth) is not AuthorizedApplication:
-            # Only specially-authorized applications are allowed to create
-            # users via this endpoint
-            raise exceptions.PermissionDenied()
-        return super(AuthorizedApplicationUserViewSet, self).create(
-            request, *args, **kwargs)
-
-
-@api_view(['POST'])
-@authentication_classes([ApplicationTokenAuthentication])
-def authorized_application_authenticate_user(request):
-    ''' Returns a user-level API token when given a valid username and
-    password. The request header must include an authorized application key '''
-    if type(request.auth) is not AuthorizedApplication:
-        # Only specially-authorized applications are allowed to authenticate
-        # users this way
-        raise exceptions.PermissionDenied()
-    serializer = AuthorizedApplicationUserSerializer(data=request.data)
-    serializer.is_valid(raise_exception=True)
-    username = serializer.validated_data['username']
-    password = serializer.validated_data['password']
-    try:
-        user = User.objects.get(username=username)
-    except User.DoesNotExist:
-        raise exceptions.PermissionDenied()
-    if not user.is_active or not user.check_password(password):
-        raise exceptions.PermissionDenied()
-    token = Token.objects.get_or_create(user=user)[0]
-    response_data = {'token': token.key}
-    user_attributes_to_return = (
-        'username',
-        'first_name',
-        'last_name',
-        'email',
-        'is_staff',
-        'is_active',
-        'is_superuser',
-        'last_login',
-        'date_joined'
-    )
-    for attribute in user_attributes_to_return:
-        response_data[attribute] = getattr(user, attribute)
-    return Response(response_data)
-
-
-class OneTimeAuthenticationKeyViewSet(
-        mixins.CreateModelMixin,
-        viewsets.GenericViewSet
-):
-    authentication_classes = [ApplicationTokenAuthentication]
-    queryset = OneTimeAuthenticationKey.objects.none()
-    serializer_class = OneTimeAuthenticationKeySerializer
-
-    def create(self, request, *args, **kwargs):
-        if type(request.auth) is not AuthorizedApplication:
-            # Only specially-authorized applications are allowed to create
-            # one-time authentication keys via this endpoint
-            raise exceptions.PermissionDenied()
-        return super(OneTimeAuthenticationKeyViewSet, self).create(
-            request, *args, **kwargs)
-
-
-@require_POST
-@csrf_exempt
-def one_time_login(request):
-    ''' If the request provides a key that matches a OneTimeAuthenticationKey
-    object, log in the User specified in that object and redirect to the
-    location specified in the 'next' parameter '''
-    try:
-        key = request.POST['key']
-    except KeyError:
-        return HttpResponseBadRequest(_('No key provided'))
-    try:
-        next_ = request.GET['next']
-    except KeyError:
-        next_ = None
-    if not next_ or not is_safe_url(url=next_, host=request.get_host()):
-        next_ = resolve_url(settings.LOGIN_REDIRECT_URL)
-    # Clean out all expired keys, just to keep the database tidier
-    OneTimeAuthenticationKey.objects.filter(
-        expiry__lt=datetime.datetime.now()).delete()
-    with transaction.atomic():
-        try:
-            otak = OneTimeAuthenticationKey.objects.get(
-                key=key,
-                expiry__gte=datetime.datetime.now()
-            )
-        except OneTimeAuthenticationKey.DoesNotExist:
-            return HttpResponseBadRequest(_('Invalid or expired key'))
-        # Nevermore
-        otak.delete()
-    # The request included a valid one-time key. Log in the associated user
-    user = otak.user
-    user.backend = settings.AUTHENTICATION_BACKENDS[0]
-    login(request, user)
-    return HttpResponseRedirect(next_)
-
-
-class XlsFormParser(MultiPartParser):
-    pass
-
-
-class ImportTaskViewSet(viewsets.ReadOnlyModelViewSet):
-    queryset = ImportTask.objects.all()
-    serializer_class = ImportTaskSerializer
-    lookup_field = 'uid'
-
-    def get_serializer_class(self):
-        if self.action == 'list':
-            return ImportTaskListSerializer
-        else:
-            return ImportTaskSerializer
-
-    def get_queryset(self, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            return ImportTask.objects.none()
-        else:
-            return ImportTask.objects.filter(
-                        user=self.request.user).order_by('date_created')
-
-    def create(self, request, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            raise exceptions.NotAuthenticated()
-        itask_data = {
-            'library': request.POST.get('library') not in ['false', False],
-            # NOTE: 'filename' here comes from 'name' (!) in the POST data
-            'filename': request.POST.get('name', None),
-            'destination': request.POST.get('destination', None),
-        }
-        if 'base64Encoded' in request.POST:
-            encoded_str = request.POST['base64Encoded']
-            encoded_substr = encoded_str[encoded_str.index('base64') + 7:]
-            itask_data['base64Encoded'] = encoded_substr
-        elif 'file' in request.data:
-            encoded_xls = base64.b64encode(request.data['file'].read())
-            itask_data['base64Encoded'] = encoded_xls
-            if 'filename' not in itask_data:
-                itask_data['filename'] = request.data['file'].name
-        elif 'url' in request.POST:
-            itask_data['single_xls_url'] = request.POST['url']
-        import_task = ImportTask.objects.create(user=request.user,
-                                                data=itask_data)
-        # Have Celery run the import in the background
-        import_in_background.delay(import_task_uid=import_task.uid)
-        return Response({
-            'uid': import_task.uid,
-            'url': reverse(
-                'importtask-detail',
-                kwargs={'uid': import_task.uid},
-                request=request),
-            'status': ImportTask.PROCESSING
-        }, status.HTTP_201_CREATED)
-
-
-class ExportTaskViewSet(NoUpdateModelViewSet):
-    queryset = ExportTask.objects.all()
-    serializer_class = ExportTaskSerializer
-    lookup_field = 'uid'
-
-    def get_queryset(self, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            return ExportTask.objects.none()
-
-        queryset = ExportTask.objects.filter(
-            user=self.request.user).order_by('date_created')
-
-        # Ultra-basic filtering by:
-        # * source URL or UID if `q=source:[URL|UID]` was provided;
-        # * comma-separated list of `ExportTask` UIDs if
-        #   `q=uid__in:[UID],[UID],...` was provided
-        q = self.request.query_params.get('q', False)
-        if not q:
-            # No filter requested
-            return queryset
-        if q.startswith('source:'):
-            q = remove_string_prefix(q, 'source:')
-            # This is exceedingly crude... but support for querying inside
-            # JSONField not available until Django 1.9
-            queryset = queryset.filter(data__contains=q)
-        elif q.startswith('uid__in:'):
-            q = remove_string_prefix(q, 'uid__in:')
-            uids = [uid.strip() for uid in q.split(',')]
-            queryset = queryset.filter(uid__in=uids)
-        else:
-            # Filter requested that we don't understand; make it obvious by
-            # returning nothing
-            return ExportTask.objects.none()
-        return queryset
-
-    def create(self, request, *args, **kwargs):
-        if self.request.user.is_anonymous():
-            raise exceptions.NotAuthenticated()
-
-        # Read valid options from POST data
-        valid_options = (
-            'type',
-            'source',
-            'group_sep',
-            'lang',
-            'hierarchy_in_labels',
-            'fields_from_all_versions',
-        )
-        task_data = {}
-        for opt in valid_options:
-            opt_val = request.POST.get(opt, None)
-            if opt_val is not None:
-                task_data[opt] = opt_val
-        # Complain if no source was specified
-        if not task_data.get('source', False):
-            raise exceptions.ValidationError(
-                {'source': 'This field is required.'})
-        # Get the source object
-        source_type, source = _resolve_url_to_asset_or_collection(
-            task_data['source'])
-        # Complain if it's not an Asset
-        if source_type != 'asset':
-            raise exceptions.ValidationError(
-                {'source': 'This field must specify an asset.'})
-        # Complain if it's not deployed
-        if not source.has_deployment:
-            raise exceptions.ValidationError(
-                {'source': 'The specified asset must be deployed.'})
-        # Create a new export task
-        export_task = ExportTask.objects.create(user=request.user,
-                                                data=task_data)
-        # Have Celery run the export in the background
-        export_in_background.delay(export_task_uid=export_task.uid)
-        return Response({
-            'uid': export_task.uid,
-            'url': reverse(
-                'exporttask-detail',
-                kwargs={'uid': export_task.uid},
-                request=request),
-            'status': ExportTask.PROCESSING
-        }, status.HTTP_201_CREATED)
-
-
-class AssetSnapshotViewSet(NoUpdateModelViewSet):
-    serializer_class = AssetSnapshotSerializer
-    lookup_field = 'uid'
-    queryset = AssetSnapshot.objects.all()
-
-    renderer_classes = NoUpdateModelViewSet.renderer_classes + [
-        XMLRenderer,
-    ]
-
-    def filter_queryset(self, queryset):
-        if (self.action == 'retrieve' and
-                self.request.accepted_renderer.format == 'xml'):
-            # The XML renderer is totally public and serves anyone, so
-            # /asset_snapshot/valid_uid.xml is world-readable, even though
-            # /asset_snapshot/valid_uid/ requires ownership. Return the
-            # queryset unfiltered
-            return queryset
-        else:
-            user = self.request.user
-            owned_snapshots = queryset.none()
-            if not user.is_anonymous():
-                owned_snapshots = queryset.filter(owner=user)
-            return owned_snapshots | RelatedAssetPermissionsFilter(
-                ).filter_queryset(self.request, queryset, view=self)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        '''
-        This route will render the XForm into syntax-highlighted HTML.
-        It is useful for debugging pyxform transformations
-        '''
-        snapshot = self.get_object()
-        response_data = copy.copy(snapshot.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if snapshot.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(snapshot.xml,
-                                                                 **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def preview(self, request, *args, **kwargs):
-        snapshot = self.get_object()
-        if snapshot.details.get('status') == 'success':
-            preview_url = "{}{}?form={}".format(
-                              settings.ENKETO_SERVER,
-                              settings.ENKETO_PREVIEW_URI,
-                              reverse(viewname='assetsnapshot-detail',
-                                      format='xml',
-                                      kwargs={'uid': snapshot.uid},
-                                      request=request,
-                                      ),
-                            )
-            return HttpResponseRedirect(preview_url)
-        else:
-            response_data = copy.copy(snapshot.details)
-            return Response(response_data, template_name='preview_error.html')
-
-
-class AssetFileViewSet(NestedViewSetMixin, NoUpdateModelViewSet):
-    model = AssetFile
-    lookup_field = 'uid'
-    filter_backends = (RelatedAssetPermissionsFilter,)
-    serializer_class = AssetFileSerializer
-
-    def get_queryset(self):
-        _asset_uid = self.get_parents_query_dict()['asset']
-        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
-        return _queryset
-
-    def perform_create(self, serializer):
-        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
-        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
-            raise exceptions.PermissionDenied()
-        serializer.save(
-            asset=asset,
-            user=self.request.user
-        )
-
-    def perform_destroy(self, *args, **kwargs):
-        asset = Asset.objects.get(uid=self.get_parents_query_dict()['asset'])
-        if not self.request.user.has_perm(PERM_CHANGE_ASSET, asset):
-            raise exceptions.PermissionDenied()
-        return super(AssetFileViewSet, self).perform_destroy(*args, **kwargs)
-
-    class PrivateContentView(PrivateStorageDetailView):
-        model = AssetFile
-        model_file_field = 'content'
-        def can_access_file(self, private_file):
-            return private_file.request.user.has_perm(
-                PERM_VIEW_ASSET, private_file.parent_object.asset)
-
-    @detail_route(methods=['get'])
-    def content(self, *args, **kwargs):
-        view = self.PrivateContentView.as_view(
-            model=AssetFile,
-            slug_url_kwarg='uid',
-            slug_field='uid',
-            model_file_field='content'
-        )
-        af = self.get_object()
-        # TODO: simply redirect if external storage with expiring tokens (e.g.
-        # Amazon S3) is used?
-        #   return HttpResponseRedirect(af.content.url)
-        return view(self.request, uid=af.uid)
-
-
-class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet):
-    """
-    ##
-    This endpoint is only used to trigger asset's hooks if any.
-
-    Tells the hooks to post an instance to external servers.
-    
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) - instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ - ## List of submissions for a specific asset - -
-    GET /assets/{asset_uid}/submissions/
-    
- - By default, JSON format is used but XML format can be used too. -
-    GET /assets/{asset_uid}/submissions.xml
-    GET /assets/{asset_uid}/submissions.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/?format=xml
-    GET /assets/{asset_uid}/submissions/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - * `id` - is the unique identifier of a specific submission - - **It's not allowed to create submissions with `kpi`'s API** - - Retrieves current submission -
-    GET /assets/{uid}/submissions/{id}/
-    
- - It's also possible to specify the format. - -
-    GET /assets/{uid}/submissions/{id}.xml
-    GET /assets/{uid}/submissions/{id}.json
-    
- - or - -
-    GET /assets/{asset_uid}/submissions/{id}/?format=xml
-    GET /assets/{asset_uid}/submissions/{id}/?format=json
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Deletes current submission -
-    DELETE /assets/{uid}/submissions/{id}/
-    
- - - > Example - > - > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - - Update current submission - - _It's not possible to update a submission directly with `kpi`'s API. - Instead, it returns the link where the instance can be opened for edition._ - -
-    GET /assets/{uid}/submissions/{id}/edit/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - - ### Validation statuses - - Retrieves the validation status of a submission. -
-    GET /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - Update the validation of a submission -
-    PATCH /assets/{uid}/submissions/{id}/validation_status/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - - > **Payload** - > - > { - > "validation_status.uid": - > } - - where `` is a string and can be one of theses values: - - - `validation_status_approved` - - `validation_status_not_approved` - - `validation_status_on_hold` - - Bulk update -
-    PATCH /assets/{uid}/submissions/validation_statuses/
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - - > **Payload** - > - > { - > "submissions_ids": [{integer}], - > "validation_status.uid": - > } - - - ### CURRENT ENDPOINT - """ - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters - - -class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - model = AssetVersion - lookup_field = 'uid' - filter_backends = ( - AssetOwnerFilterBackend, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetVersionListSerializer - else: - return AssetVersionSerializer - - def get_queryset(self): - _asset_uid = self.get_parents_query_dict()['asset'] - _deployed = self.request.query_params.get('deployed', None) - _queryset = self.model.objects.filter(asset__uid=_asset_uid) - if _deployed is not None: - _queryset = _queryset.filter(deployed=_deployed) - if self.action == 'list': - # Save time by only retrieving fields from the DB that the - # serializer will use - _queryset = _queryset.only( - 'uid', 'deployed', 'date_modified', 'asset_id') - # `AssetVersionListSerializer.get_url()` asks for the asset UID - _queryset = _queryset.select_related('asset__uid') - return _queryset - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ - * Assign a asset to a collection partially implemented - * Run a partial update of a asset TODO - - TODO Complete documentation - - ## List of asset endpoints - - Lists the asset endpoints accessible to requesting user, for anonymous access - a list of public data endpoints is returned. - -
-    GET /assets/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/ - - Get an hash of all `version_id`s of assets. - Useful to detect any changes in assets with only one call to `API` - -
-    GET /assets/hash/
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/hash/ - - ## CRUD - - * `uid` - is the unique identifier of a specific asset - - Retrieves current asset -
-    GET /assets/{uid}/
-    
- - - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - - Creates or clones an asset. -
-    POST /assets/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/ - - - > **Payload to create a new asset** - > - > { - > "name": {string}, - > "settings": { - > "description": {string}, - > "sector": {string}, - > "country": {string}, - > "share-metadata": {boolean} - > }, - > "asset_type": {string} - > } - - > **Payload to clone an asset** - > - > { - > "clone_from": {string}, - > "name": {string}, - > "asset_type": {string} - > } - - where `asset_type` must be one of these values: - - * block (can be cloned to `block`, `question`, `survey`, `template`) - * question (can be cloned to `question`, `survey`, `template`) - * survey (can be cloned to `block`, `question`, `survey`, `template`) - * template (can be cloned to `survey`, `template`) - - Settings are cloned only when type of assets are `survey` or `template`. - In that case, `share-metadata` is not preserved. - - When creating a new `block` or `question` asset, settings are not saved either. - - ### Deployment - - Retrieves the existing deployment, if any. -
-    GET /assets/{uid}/deployment
-    
- - > Example - > - > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Creates a new deployment, but only if a deployment does not exist already. -
-    POST /assets/{uid}/deployment
-    
- - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Updates the `active` field of the existing deployment. -
-    PATCH /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier -
-    PUT /assets/{uid}/deployment
-    
- - > Example - > - > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - - ### Permissions - Updates permissions of the specific asset -
-    PATCH /assets/{uid}/permissions
-    
- - > Example - > - > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - - ### CURRENT ENDPOINT - """ - - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - - def get_serializer_class(self): - if self.action == 'list': - return AssetListSerializer - else: - return AssetSerializer - - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY)\ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
-    )
-    def deployment(self, request, uid):
-        '''
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        '''
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                        )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
-    def permissions(self, request, uid):
-        target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
-        user = request.user
-        response = {}
-        http_status = status.HTTP_204_NO_CONTENT
-
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-            user.has_perm(PERM_VIEW_ASSET, source_asset):
-            if not target_asset.copy_permissions_from(source_asset):
-                http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
-        else:
-            raise exceptions.PermissionDenied()
-
-        return Response(response, status=http_status)
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        ''' Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        '''
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
-
-
-def _wrap_html_pre(content):
-    return "
%s
" % content - - -class SitewideMessageViewSet(viewsets.ModelViewSet): - queryset = SitewideMessage.objects.all() - serializer_class = SitewideMessageSerializer - - -class UserCollectionSubscriptionViewSet(viewsets.ModelViewSet): - queryset = UserCollectionSubscription.objects.none() - serializer_class = UserCollectionSubscriptionSerializer - lookup_field = 'uid' - - def get_queryset(self): - user = self.request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - criteria = {'user': user} - if 'collection__uid' in self.request.query_params: - criteria['collection__uid'] = self.request.query_params[ - 'collection__uid'] - return UserCollectionSubscription.objects.filter(**criteria) - - def perform_create(self, serializer): - serializer.save(user=self.request.user) - - -class TokenView(APIView): - def _which_user(self, request): - ''' - Determine the user from `request`, allowing superusers to specify - another user by passing the `username` query parameter - ''' - if request.user.is_anonymous(): - raise exceptions.NotAuthenticated() - - if 'username' in request.query_params: - # Allow superusers to get others' tokens - if request.user.is_superuser: - user = get_object_or_404( - User, - username=request.query_params['username'] - ) - else: - raise exceptions.PermissionDenied() - else: - user = request.user - return user - - def get(self, request, *args, **kwargs): - ''' Retrieve an existing token only ''' - user = self._which_user(request) - token = get_object_or_404(Token, user=user) - return Response({'token': token.key}) - - def post(self, request, *args, **kwargs): - ''' Return a token, creating a new one if none exists ''' - user = self._which_user(request) - token, created = Token.objects.get_or_create(user=user) - return Response( - {'token': token.key}, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK - ) - - def delete(self, request, *args, **kwargs): - ''' Delete an existing token and do not generate a new one ''' - user = self._which_user(request) - with transaction.atomic(): - token = get_object_or_404(Token, user=user) - token.delete() - return Response({}, status=status.HTTP_204_NO_CONTENT) - - -class EnvironmentView(APIView): - ''' GET-only view for certain server-provided configuration data ''' - - CONFIGS_TO_EXPOSE = [ - 'TERMS_OF_SERVICE_URL', - 'PRIVACY_POLICY_URL', - 'SOURCE_CODE_URL', - 'SUPPORT_URL', - 'SUPPORT_EMAIL', - ] - - def get(self, request, *args, **kwargs): - ''' - Return the lowercased key and value of each setting in - `CONFIGS_TO_EXPOSE` - ''' - return Response({ - key.lower(): getattr(constance.config, key) - for key in self.CONFIGS_TO_EXPOSE - }) ->>>>>>> WIP - splitted views.py in several files -======= ->>>>>>> Removed useless imports and unrelated codes from views files diff --git a/kpi/views/v1/export_task.py b/kpi/views/v1/export_task.py index 46580b8768..24f759ad86 100644 --- a/kpi/views/v1/export_task.py +++ b/kpi/views/v1/export_task.py @@ -1,10 +1,4 @@ -<<<<<<< HEAD # coding: utf-8 -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - ->>>>>>> Moved current views and serializers under 'v1' parent folder from rest_framework import status, exceptions from rest_framework.response import Response from rest_framework.reverse import reverse @@ -14,11 +8,8 @@ from kpi.model_utils import remove_string_prefix from kpi.serializers import ExportTaskSerializer from kpi.tasks import export_in_background -<<<<<<< HEAD + from kpi.views.no_update_model import NoUpdateModelViewSet -======= -from .no_update_model import NoUpdateModelViewSet ->>>>>>> Moved current views and serializers under 'v1' parent folder class ExportTaskViewSet(NoUpdateModelViewSet): @@ -27,11 +18,7 @@ class ExportTaskViewSet(NoUpdateModelViewSet): lookup_field = 'uid' def get_queryset(self, *args, **kwargs): -<<<<<<< HEAD if self.request.user.is_anonymous: -======= - if self.request.user.is_anonymous(): ->>>>>>> Moved current views and serializers under 'v1' parent folder return ExportTask.objects.none() queryset = ExportTask.objects.filter( @@ -61,11 +48,7 @@ def get_queryset(self, *args, **kwargs): return queryset def create(self, request, *args, **kwargs): -<<<<<<< HEAD if self.request.user.is_anonymous: -======= - if self.request.user.is_anonymous(): ->>>>>>> Moved current views and serializers under 'v1' parent folder raise exceptions.NotAuthenticated() # Read valid options from POST data diff --git a/kpi/views/v1/object_permission.py b/kpi/views/v1/object_permission.py index 0de031bf2f..7a926e338e 100644 --- a/kpi/views/v1/object_permission.py +++ b/kpi/views/v1/object_permission.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD # coding: utf-8 from django.db import transaction from rest_framework import exceptions @@ -8,19 +7,8 @@ from kpi.serializers import ObjectPermissionSerializer from kpi.views.no_update_model import NoUpdateModelViewSet from kpi.utils.object_permission_helper import ObjectPermissionHelper -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import -from django.db import transaction -from rest_framework import exceptions - -from kpi.constants import PERM_SHARE_SUBMISSIONS -from kpi.filters import KpiAssignedObjectPermissionsFilter -from kpi.models import ObjectPermission -from kpi.serializers import ObjectPermissionSerializer from .no_update_model import NoUpdateModelViewSet ->>>>>>> Moved current views and serializers under 'v1' parent folder class ObjectPermissionViewSet(NoUpdateModelViewSet): @@ -29,41 +17,15 @@ class ObjectPermissionViewSet(NoUpdateModelViewSet): lookup_field = 'uid' filter_backends = (KpiAssignedObjectPermissionsFilter, ) -<<<<<<< HEAD -======= - def _requesting_user_can_share(self, affected_object, codename): - r""" - Return `True` if `self.request.user` is allowed to grant and revoke - `codename` on `affected_object`. For `Collection`, this is always - the same as checking that `self.request.user` has the - `share_collection` permission on `affected_object`. For `Asset`, - the result is determined by either `share_asset` or - `share_submissions`, depending on the `codename`. - :type affected_object: :py:class:Asset or :py:class:Collection - :type codename: str - :rtype bool - """ - model_name = affected_object._meta.model_name - if model_name == 'asset' and codename.endswith('_submissions'): - share_permission = PERM_SHARE_SUBMISSIONS - else: - share_permission = 'share_{}'.format(model_name) - return affected_object.has_perm(self.request.user, share_permission) - ->>>>>>> Moved current views and serializers under 'v1' parent folder def perform_create(self, serializer): # Make sure the requesting user has the share_ permission on # the affected object with transaction.atomic(): affected_object = serializer.validated_data['content_object'] codename = serializer.validated_data['permission'].codename -<<<<<<< HEAD if not ObjectPermissionHelper.user_can_share(affected_object, self.request.user, codename): -======= - if not self._requesting_user_can_share(affected_object, codename): ->>>>>>> Moved current views and serializers under 'v1' parent folder raise exceptions.PermissionDenied() serializer.save() @@ -80,13 +42,9 @@ def perform_destroy(self, instance): with transaction.atomic(): affected_object = instance.content_object codename = instance.permission.codename -<<<<<<< HEAD if not ObjectPermissionHelper.user_can_share(affected_object, self.request.user, codename): -======= - if not self._requesting_user_can_share(affected_object, codename): ->>>>>>> Moved current views and serializers under 'v1' parent folder raise exceptions.PermissionDenied() instance.content_object.remove_perm( instance.user, From 65a354a82df2e990985673cba12e80ae02ed4311 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 2 May 2019 15:00:00 -0400 Subject: [PATCH 036/499] Imports reorder --- kpi/views/token.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kpi/views/token.py b/kpi/views/token.py index df028f4b69..7a78cfa964 100644 --- a/kpi/views/token.py +++ b/kpi/views/token.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from rest_framework.views import APIView + class TokenView(APIView): def _which_user(self, request): """ From 8ab5dcde84f6cf2b4d6366aa6f30ff27ac9234c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Tue, 7 May 2019 15:51:51 -0400 Subject: [PATCH 037/499] WIP - Start to include views and serializers related to Asset in v2 --- .../relative_prefix_hyperlinked_related.py | 2 + kpi/fields/versioned_hyperlinked_identity.py | 11 + kpi/fields/versioned_hyperlinked_related.py | 27 + kpi/serializers/create_user.py | 1237 ---------------- kpi/serializers/current_user.py | 1245 +---------------- kpi/serializers/v2/object_permission.py | 84 ++ kpi/utils/url_helper.py | 61 + kpi/views/v1/asset.py | 454 +----- kpi/views/v1/collection.py | 100 -- kpi/views/v1/user.py | 31 +- kpi/views/v2/collection.py | 1 - 11 files changed, 240 insertions(+), 3013 deletions(-) create mode 100644 kpi/fields/versioned_hyperlinked_identity.py create mode 100644 kpi/fields/versioned_hyperlinked_related.py create mode 100644 kpi/serializers/v2/object_permission.py create mode 100644 kpi/utils/url_helper.py diff --git a/kpi/fields/relative_prefix_hyperlinked_related.py b/kpi/fields/relative_prefix_hyperlinked_related.py index 85f9501855..3c3d2009fc 100644 --- a/kpi/fields/relative_prefix_hyperlinked_related.py +++ b/kpi/fields/relative_prefix_hyperlinked_related.py @@ -4,6 +4,8 @@ from django.urls import get_script_prefix from rest_framework.serializers import HyperlinkedRelatedField +from .versioned_hyperlinked_related import VersionedHyperlinkedRelatedField + class RelativePrefixHyperlinkedRelatedField(HyperlinkedRelatedField): diff --git a/kpi/fields/versioned_hyperlinked_identity.py b/kpi/fields/versioned_hyperlinked_identity.py new file mode 100644 index 0000000000..256e23465d --- /dev/null +++ b/kpi/fields/versioned_hyperlinked_identity.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from rest_framework.serializers import HyperlinkedIdentityField +from .versioned_hyperlinked_related import VersionedHyperlinkedRelatedField + + +class VersionedHyperlinkedIdentityField(HyperlinkedIdentityField, + VersionedHyperlinkedRelatedField): + + pass diff --git a/kpi/fields/versioned_hyperlinked_related.py b/kpi/fields/versioned_hyperlinked_related.py new file mode 100644 index 0000000000..a44ae07584 --- /dev/null +++ b/kpi/fields/versioned_hyperlinked_related.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from rest_framework.serializers import HyperlinkedRelatedField + +from kpi.utils.url_helper import UrlHelper + + +class VersionedHyperlinkedRelatedField(HyperlinkedRelatedField): + """ + Extends `DRF.HyperlinkedRelatedField` to support versions of kpi's API. + Can not use DRF native versioning classes because of the structure of + urls of V1. + """ + def get_url(self, obj, view_name, request, format): + # Unsaved objects will not yet have a valid URL. + if hasattr(obj, 'pk') and obj.pk in (None, ''): + return None + + lookup_value = getattr(obj, self.lookup_field) + kwargs = {self.lookup_url_kwarg: lookup_value} + + return UrlHelper.reverse(view_name, + kwargs=kwargs, + request=request, + context=self.context, + format=format) diff --git a/kpi/serializers/create_user.py b/kpi/serializers/create_user.py index 9a347d4621..1f49ec3afd 100644 --- a/kpi/serializers/create_user.py +++ b/kpi/serializers/create_user.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD # coding: utf-8 from django.contrib.auth.models import User from rest_framework import serializers @@ -6,1032 +5,6 @@ from kpi.forms import USERNAME_REGEX from kpi.forms import USERNAME_MAX_LENGTH from kpi.forms import USERNAME_INVALID_MESSAGE -======= -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } - - -class CurrentUserSerializer(serializers.ModelSerializer): - email = serializers.EmailField() - server_time = serializers.SerializerMethodField() - date_joined = serializers.SerializerMethodField() - projects_url = serializers.SerializerMethodField() - gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() - extra_details = WritableJSONField(source='extra_details.data') - current_password = serializers.CharField(write_only=True, required=False) - new_password = serializers.CharField(write_only=True, required=False) - git_rev = serializers.SerializerMethodField() - - class Meta: - model = User - fields = ( - 'username', - 'first_name', - 'last_name', - 'email', - 'server_time', - 'date_joined', - 'projects_url', - 'is_superuser', - 'gravatar', - 'is_staff', - 'last_login', - 'languages', - 'extra_details', - 'current_password', - 'new_password', - 'git_rev', - ) - - def get_server_time(self, obj): - # Currently unused on the front end - return datetime.datetime.now(tz=pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_date_joined(self, obj): - return obj.date_joined.astimezone(pytz.UTC).strftime( - '%Y-%m-%dT%H:%M:%SZ') - - def get_projects_url(self, obj): - return '/'.join((settings.KOBOCAT_URL, obj.username)) - - def get_gravatar(self, obj): - return gravatar_url(obj.email) - - def get_languages(self, obj): - return settings.LANGUAGES - - def get_git_rev(self, obj): - request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): - return settings.GIT_REV - else: - return False - - def to_representation(self, obj): - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES - if not rep['extra_details']: - rep['extra_details'] = {} - # `require_auth` needs to be read from KC every time - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL: - rep['extra_details']['require_auth'] = get_kc_profile_data( - obj.pk).get('require_auth', False) - - return rep - - def update(self, instance, validated_data): - # "The `.update()` method does not support writable dotted-source - # fields by default." --DRF - extra_details = validated_data.pop('extra_details', False) - if extra_details: - extra_details_obj, created = ExtraUserDetail.objects.get_or_create( - user=instance) - # `require_auth` needs to be written back to KC - if settings.KOBOCAT_URL and settings.KOBOCAT_INTERNAL_URL and \ - 'require_auth' in extra_details['data']: - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth']) - extra_details_obj.data.update(extra_details['data']) - extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) ->>>>>>> WIP - split serializers class CreateUserSerializer(serializers.ModelSerializer): @@ -1041,10 +14,7 @@ class CreateUserSerializer(serializers.ModelSerializer): error_messages={'invalid': USERNAME_INVALID_MESSAGE} ) email = serializers.EmailField() -<<<<<<< HEAD -======= ->>>>>>> WIP - split serializers class Meta: model = User fields = ( @@ -1053,12 +23,6 @@ class Meta: 'first_name', 'last_name', 'email', -<<<<<<< HEAD -======= - #'is_staff', - #'is_superuser', - #'is_active', ->>>>>>> WIP - split serializers ) extra_kwargs = { 'password': {'write_only': True}, @@ -1080,204 +44,3 @@ def create(self, validated_data): pass user.save() return user -<<<<<<< HEAD -======= - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data - - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') ->>>>>>> WIP - split serializers diff --git a/kpi/serializers/current_user.py b/kpi/serializers/current_user.py index 54d5eb1b52..b01a05c7c4 100644 --- a/kpi/serializers/current_user.py +++ b/kpi/serializers/current_user.py @@ -1,932 +1,19 @@ -<<<<<<< HEAD # coding: utf-8 import datetime import pytz +import constance from django.contrib.auth import update_session_auth_hash from django.contrib.auth.models import User -from django.db import transaction from django.conf import settings +from django.utils.translation import ugettext as _ from rest_framework import serializers -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES from hub.models import ExtraUserDetail from kpi.deployment_backends.kc_access.utils import get_kc_profile_data from kpi.deployment_backends.kc_access.utils import set_kc_require_auth from kpi.fields import WritableJSONField from kpi.utils.gravatar_url import gravatar_url -======= -# -*- coding: utf-8 -*- -import datetime -import json -import pytz -from collections import OrderedDict - -import constance -from django.contrib.auth.models import User, Permission -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.core.urlresolvers import get_script_prefix, resolve, Resolver404 -from django.db import transaction -from django.db.utils import ProgrammingError -from django.utils.six.moves.urllib import parse as urlparse -from django.conf import settings -from rest_framework import serializers, exceptions -from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination -from rest_framework.reverse import reverse_lazy, reverse -from taggit.models import Tag - -from kobo.static_lists import SECTORS, COUNTRIES, LANGUAGES -from kpi.constants import PERM_VIEW_ASSET, PERM_VIEW_COLLECTION, PERM_FROM_KC_ONLY -from hub.models import SitewideMessage, ExtraUserDetail -from .fields import PaginatedApiField, SerializerMethodFileField -from .models import Asset -from .models import AssetSnapshot -from .models import AssetVersion -from .models import AssetFile -from .models import Collection -from .models import CollectionChildrenQuerySet -from .models import UserCollectionSubscription -from .models import ImportTask, ExportTask -from .models import ObjectPermission -from .models.object_permission import get_anonymous_user, get_objects_for_user -from .models.asset import ASSET_TYPES -from .models import TagUid -from .models import OneTimeAuthenticationKey -from .forms import USERNAME_REGEX, USERNAME_MAX_LENGTH -from .forms import USERNAME_INVALID_MESSAGE -from .utils.gravatar_url import gravatar_url - -from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth - - -class Paginated(LimitOffsetPagination): - - """ Adds 'root' to the wrapping response object. """ - root = serializers.SerializerMethodField('get_parent_url', read_only=True) - - def get_parent_url(self, obj): - return reverse_lazy('api-root', request=self.context.get('request')) - - -class TinyPaginated(PageNumberPagination): - """ - Same as Paginated with a small page size - """ - page_size = 50 - - -class WritableJSONField(serializers.Field): - - """ Serializer for JSONField -- required to make field writable""" - - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - super(WritableJSONField, self).__init__(**kwargs) - - def to_internal_value(self, data): - if (not data) and (not self.required): - return None - else: - try: - return json.loads(data) - except Exception as e: - raise serializers.ValidationError( - u'Unable to parse JSON: {}'.format(e)) - - def to_representation(self, value): - return value - - -class ReadOnlyJSONField(serializers.ReadOnlyField): - def to_representation(self, value): - return value - - -class GenericHyperlinkedRelatedField(serializers.HyperlinkedRelatedField): - - def __init__(self, **kwargs): - # These arguments are required by ancestors but meaningless in our - # situation. We will override them dynamically. - kwargs['view_name'] = '*' - kwargs['queryset'] = ObjectPermission.objects.none() - return super(GenericHyperlinkedRelatedField, self).__init__(**kwargs) - - def to_representation(self, value): - self.view_name = '{}-detail'.format( - ContentType.objects.get_for_model(value).model) - result = super(GenericHyperlinkedRelatedField, self).to_representation( - value) - self.view_name = '*' - return result - - def to_internal_value(self, data): - ''' The vast majority of this method has been copied and pasted from - HyperlinkedRelatedField.to_internal_value(). Modifications exist - to allow any type of object. ''' - _ = self.context.get('request', None) - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - try: - match = resolve(data) - except Resolver404: - self.fail('no_match') - - ### Begin modifications ### - # We're a generic relation; we don't discriminate - ''' - try: - expected_viewname = request.versioning_scheme.get_versioned_viewname( - self.view_name, request - ) - except AttributeError: - expected_viewname = self.view_name - - if match.view_name != expected_viewname: - self.fail('incorrect_match') - ''' - - # Dynamically modify the queryset - self.queryset = match.func.cls.queryset - ### End modifications ### - - try: - return self.get_object(match.view_name, match.args, match.kwargs) - except (ObjectDoesNotExist, TypeError, ValueError): - self.fail('does_not_exist') - - -class RelativePrefixHyperlinkedRelatedField( - serializers.HyperlinkedRelatedField): - def to_internal_value(self, data): - try: - http_prefix = data.startswith(('http:', 'https:')) - except AttributeError: - self.fail('incorrect_type', data_type=type(data).__name__) - - # The script prefix must be removed even if the URL is relative. - # TODO: Figure out why DRF only strips absolute URLs, or file bug - if True or http_prefix: - # If needed convert absolute URLs to relative path - data = urlparse.urlparse(data).path - prefix = get_script_prefix() - if data.startswith(prefix): - data = '/' + data[len(prefix):] - - return super( - RelativePrefixHyperlinkedRelatedField, self - ).to_internal_value(data) - - -class TagSerializer(serializers.ModelSerializer): - url = serializers.SerializerMethodField('_get_tag_url', read_only=True) - assets = serializers.SerializerMethodField('_get_assets', read_only=True) - collections = serializers.SerializerMethodField( - '_get_collections', read_only=True) - parent = serializers.SerializerMethodField( - '_get_parent_url', read_only=True) - uid = serializers.ReadOnlyField(source='taguid.uid') - - class Meta: - model = Tag - fields = ('name', 'url', 'assets', 'collections', 'parent', 'uid') - - def _get_parent_url(self, obj): - return reverse('tag-list', request=self.context.get('request', None)) - - def _get_assets(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('asset-detail', args=(sa.uid,), request=request) - for sa in Asset.objects.filter(tags=obj, owner=user).all()] - - def _get_collections(self, obj): - request = self.context.get('request', None) - user = request.user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - return [reverse('collection-detail', args=(coll.uid,), request=request) - for coll in Collection.objects.filter(tags=obj, owner=user) - .all()] - - def _get_tag_url(self, obj): - request = self.context.get('request', None) - uid = TagUid.objects.get_or_create(tag=obj)[0].uid - return reverse('tag-detail', args=(uid,), request=request) - - -class TagListSerializer(TagSerializer): - - class Meta: - model = Tag - fields = ('name', 'url', ) - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - #'content_object', - 'deny', - 'inherited', - ) - - -class AncestorCollectionsSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - - class Meta: - model = Collection - fields = ('name', 'uid', 'url') - - -class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='assetsnapshot-detail') - uid = serializers.ReadOnlyField() - xml = serializers.SerializerMethodField() - enketopreviewlink = serializers.SerializerMethodField() - details = WritableJSONField(required=False) - asset = RelativePrefixHyperlinkedRelatedField( - queryset=Asset.objects.all(), view_name='asset-detail', - lookup_field='uid', - required=False, - allow_null=True, - style={'base_template': 'input.html'} # Render as a simple text box - ) - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - asset_version_id = serializers.ReadOnlyField() - date_created = serializers.DateTimeField(read_only=True) - source = WritableJSONField(required=False) - - def get_xml(self, obj): - ''' There's too much magic in HyperlinkedIdentityField. When format is - unspecified by the request, HyperlinkedIdentityField.to_representation() - refuses to append format to the url. We want to *unconditionally* - include the xml format suffix. ''' - return reverse( - viewname='assetsnapshot-detail', format='xml', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def get_enketopreviewlink(self, obj): - return reverse( - viewname='assetsnapshot-preview', - kwargs={'uid': obj.uid}, - request=self.context.get('request', None) - ) - - def create(self, validated_data): - ''' Create a snapshot of an asset, either by copying an existing - asset's content or by accepting the source directly in the request. - Transform the source into XML that's then exposed to Enketo - (and the www). ''' - asset = validated_data.get('asset', None) - source = validated_data.get('source', None) - - # Force owner to be the requesting user - # NB: validated_data is not used when linking to an existing asset - # without specifying source; in that case, the snapshot owner is the - # asset's owner, even if a different user makes the request - validated_data['owner'] = self.context['request'].user - - # TODO: Move to a validator? - if asset and source: - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - validated_data['source'] = source - snapshot = AssetSnapshot.objects.create(**validated_data) - elif asset: - # The client provided an existing asset; read source from it - if not self.context['request'].user.has_perm(PERM_VIEW_ASSET, asset): - # The client is not allowed to snapshot this asset - raise exceptions.PermissionDenied - # asset.snapshot pulls , by default, a snapshot for the latest - # version. - snapshot = asset.snapshot - elif source: - # The client provided source directly; no need to copy anything - # For tidiness, pop off unused fields. `None` avoids KeyError - validated_data.pop('asset', None) - validated_data.pop('asset_version', None) - snapshot = AssetSnapshot.objects.create(**validated_data) - else: - raise serializers.ValidationError('Specify an asset and/or a source') - - if not snapshot.xml: - raise serializers.ValidationError(snapshot.details) - return snapshot - - class Meta: - model = AssetSnapshot - lookup_field = 'uid' - fields = ('url', - 'uid', - 'owner', - 'date_created', - 'xml', - 'enketopreviewlink', - 'asset', - 'asset_version_id', - 'details', - 'source', - ) - - -class AssetFileSerializer(serializers.ModelSerializer): - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - asset = RelativePrefixHyperlinkedRelatedField( - view_name='asset-detail', lookup_field='uid', read_only=True) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - user__username = serializers.ReadOnlyField(source='user.username') - file_type = serializers.ChoiceField(choices=AssetFile.TYPE_CHOICES) - name = serializers.CharField() - date_created = serializers.ReadOnlyField() - content = SerializerMethodFileField() - metadata = WritableJSONField(required=False) - - def get_url(self, obj): - return reverse('asset-file-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - def get_content(self, obj, *args, **kwargs): - return reverse('asset-file-content', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - class Meta: - model = AssetFile - fields = ( - 'uid', - 'url', - 'asset', - 'user', - 'user__username', - 'file_type', - 'name', - 'date_created', - 'content', - 'metadata', - ) - - -class AssetVersionListSerializer(serializers.Serializer): - # If you change these fields, please update the `only()` and - # `select_related()` calls in `AssetVersionViewSet.get_queryset()` - uid = serializers.ReadOnlyField() - url = serializers.SerializerMethodField() - content_hash = serializers.ReadOnlyField() - date_deployed = serializers.SerializerMethodField(read_only=True) - date_modified = serializers.CharField(read_only=True) - - def get_date_deployed(self, obj): - return obj.deployed and obj.date_modified - - def get_url(self, obj): - return reverse('asset-version-detail', args=(obj.asset.uid, obj.uid), - request=self.context.get('request', None)) - - -class AssetVersionSerializer(AssetVersionListSerializer): - content = serializers.SerializerMethodField(read_only=True) - - def get_content(self, obj): - return obj.version_content - - def get_version_id(self, obj): - return obj.uid - - class Meta: - model = AssetVersion - fields = ( - 'version_id', - 'date_deployed', - 'date_modified', - 'content_hash', - 'content', - ) - - -class AssetSerializer(serializers.HyperlinkedModelSerializer): - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', lookup_field='username', read_only=True) - owner__username = serializers.ReadOnlyField(source='owner.username') - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='asset-detail') - asset_type = serializers.ChoiceField(choices=ASSET_TYPES) - settings = WritableJSONField(required=False, allow_blank=True) - content = WritableJSONField(required=False) - report_styles = WritableJSONField(required=False) - report_custom = WritableJSONField(required=False) - map_styles = WritableJSONField(required=False) - map_custom = WritableJSONField(required=False) - xls_link = serializers.SerializerMethodField() - summary = serializers.ReadOnlyField() - koboform_link = serializers.SerializerMethodField() - xform_link = serializers.SerializerMethodField() - version_count = serializers.SerializerMethodField() - downloads = serializers.SerializerMethodField() - embeds = serializers.SerializerMethodField() - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - queryset=Collection.objects.all(), - view_name='collection-detail', - required=False, - allow_null=True - ) - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - permissions = ObjectPermissionNestedSerializer(many=True, read_only=True) - tag_string = serializers.CharField(required=False, allow_blank=True) - version_id = serializers.CharField(read_only=True) - version__content_hash = serializers.CharField(read_only=True) - has_deployment = serializers.ReadOnlyField() - deployed_version_id = serializers.SerializerMethodField() - deployed_versions = PaginatedApiField( - serializer_class=AssetVersionListSerializer, - # Higher-than-normal limit since the client doesn't yet know how to - # request more than the first page - default_limit=100 - ) - deployment__identifier = serializers.SerializerMethodField() - deployment__active = serializers.SerializerMethodField() - deployment__links = serializers.SerializerMethodField() - deployment__data_download_links = serializers.SerializerMethodField() - deployment__submission_count = serializers.SerializerMethodField() - - # Only add link instead of hooks list to avoid multiple access to DB. - hooks_link = serializers.SerializerMethodField() - - class Meta: - model = Asset - lookup_field = 'uid' - fields = ('url', - 'owner', - 'owner__username', - 'parent', - 'ancestors', - 'settings', - 'asset_type', - 'date_created', - 'summary', - 'date_modified', - 'version_id', - 'version__content_hash', - 'version_count', - 'has_deployment', - 'deployed_version_id', - 'deployed_versions', - 'deployment__identifier', - 'deployment__links', - 'deployment__active', - 'deployment__data_download_links', - 'deployment__submission_count', - 'report_styles', - 'report_custom', - 'map_styles', - 'map_custom', - 'content', - 'downloads', - 'embeds', - 'koboform_link', - 'xform_link', - 'hooks_link', - 'tag_string', - 'uid', - 'kind', - 'xls_link', - 'name', - 'permissions', - 'settings',) - extra_kwargs = { - 'parent': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def update(self, asset, validated_data): - asset_content = asset.content - _req_data = self.context['request'].data - _has_translations = 'translations' in _req_data - _has_content = 'content' in _req_data - if _has_translations and not _has_content: - translations_list = json.loads(_req_data['translations']) - try: - asset.update_translation_list(translations_list) - except ValueError as err: - raise serializers.ValidationError(err.message) - validated_data['content'] = asset_content - return super(AssetSerializer, self).update(asset, validated_data) - - def get_fields(self, *args, **kwargs): - fields = super(AssetSerializer, self).get_fields(*args, **kwargs) - user = self.context['request'].user - # Check if the user is anonymous. The - # django.contrib.auth.models.AnonymousUser object doesn't work for - # queries. - if user.is_anonymous(): - user = get_anonymous_user() - if 'parent' in fields: - # TODO: remove this restriction? - fields['parent'].queryset = fields['parent'].queryset.filter( - owner=user) - # Honor requests to exclude fields - # TODO: Actually exclude fields from tha database query! DRF grabs - # all columns, even ones that are never named in `fields` - excludes = self.context['request'].GET.get('exclude', '') - for exclude in excludes.split(','): - exclude = exclude.strip() - if exclude in fields: - fields.pop(exclude) - return fields - - def get_version_count(self, obj): - return obj.asset_versions.count() - - def get_xls_link(self, obj): - return reverse('asset-xls', args=(obj.uid,), request=self.context.get('request', None)) - - def get_xform_link(self, obj): - return reverse('asset-xform', args=(obj.uid,), request=self.context.get('request', None)) - - def get_hooks_link(self, obj): - return reverse('hook-list', args=(obj.uid,), request=self.context.get('request', None)) - - def get_embeds(self, obj): - request = self.context.get('request', None) - - def _reverse_lookup_format(fmt): - url = reverse('asset-%s' % fmt, - args=(obj.uid,), - request=request) - return {'format': fmt, - 'url': url, } - base_url = reverse('asset-detail', - args=(obj.uid,), - request=request) - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xform'), - ] - - def get_downloads(self, obj): - def _reverse_lookup_format(fmt): - request = self.context.get('request', None) - obj_url = reverse('asset-detail', args=(obj.uid,), request=request) - # The trailing slash must be removed prior to appending the format - # extension - url = '%s.%s' % (obj_url.rstrip('/'), fmt) - - return {'format': fmt, - 'url': url, } - return [ - _reverse_lookup_format('xls'), - _reverse_lookup_format('xml'), - ] - - def get_koboform_link(self, obj): - return reverse('asset-koboform', args=(obj.uid,), request=self.context - .get('request', None)) - - def get_deployed_version_id(self, obj): - if not obj.has_deployment: - return - if isinstance(obj.deployment.version_id, int): - asset_versions_uids_only = obj.asset_versions.only('uid') - # this can be removed once the 'replace_deployment_ids' - # migration has been run - v_id = obj.deployment.version_id - try: - return asset_versions_uids_only.get( - _reversion_version_id=v_id - ).uid - except AssetVersion.DoesNotExist: - deployed_version = asset_versions_uids_only.filter( - deployed=True - ).first() - if deployed_version: - return deployed_version.uid - else: - return None - else: - return obj.deployment.version_id - - def get_deployment__identifier(self, obj): - if obj.has_deployment: - return obj.deployment.identifier - - def get_deployment__active(self, obj): - return obj.has_deployment and obj.deployment.active - - def get_deployment__links(self, obj): - if obj.has_deployment and obj.deployment.active: - return obj.deployment.get_enketo_survey_links() - else: - return {} - - def get_deployment__data_download_links(self, obj): - if obj.has_deployment: - return obj.deployment.get_data_download_links() - else: - return {} - - def get_deployment__submission_count(self, obj): - if not obj.has_deployment: - return 0 - return obj.deployment.submission_count - - def _content(self, obj): - return json.dumps(obj.content) - - def _table_url(self, obj): - request = self.context.get('request', None) - return reverse('asset-table-view', args=(obj.uid,), request=request) - - -class DeploymentSerializer(serializers.Serializer): - backend = serializers.CharField(required=False) - identifier = serializers.CharField(read_only=True) - active = serializers.BooleanField(required=False) - version_id = serializers.CharField(required=False) - asset = serializers.SerializerMethodField() - - @staticmethod - def _raise_unless_current_version(asset, validated_data): - # Stop if the requester attempts to deploy any version of the asset - # except the current one - if 'version_id' in validated_data and \ - validated_data['version_id'] != str(asset.version_id): - raise NotImplementedError( - 'Only the current version_id can be deployed') - - def get_asset(self, obj): - asset = self.context['asset'] - return AssetSerializer(asset, context=self.context).data - - def create(self, validated_data): - asset = self.context['asset'] - self._raise_unless_current_version(asset, validated_data) - # if no backend is provided, use the installation's default backend - backend_id = validated_data.get('backend', - settings.DEFAULT_DEPLOYMENT_BACKEND) - - # asset.deploy deploys the latest version and updates that versions' - # 'deployed' boolean value - asset.deploy(backend=backend_id, - active=validated_data.get('active', False)) - asset.save(create_version=False, - adjust_content=False) - return asset.deployment - - def update(self, instance, validated_data): - ''' If a `version_id` is provided and differs from the current - deployment's `version_id`, the asset will be redeployed. Otherwise, - only the `active` field will be updated ''' - asset = self.context['asset'] - deployment = asset.deployment - - if 'backend' in validated_data and \ - validated_data['backend'] != deployment.backend: - raise exceptions.ValidationError( - {'backend': 'This field cannot be modified after the initial ' - 'deployment.'}) - - if ('version_id' in validated_data and - validated_data['version_id'] != deployment.version_id): - # Request specified a `version_id` that differs from the current - # deployment's; redeploy - self._raise_unless_current_version(asset, validated_data) - asset.deploy( - backend=deployment.backend, - active=validated_data.get('active', deployment.active) - ) - elif 'active' in validated_data: - # Set the `active` flag without touching the rest of the deployment - deployment.set_active(validated_data['active']) - - asset.save(create_version=False, adjust_content=False) - return deployment - - -class ImportTaskSerializer(serializers.HyperlinkedModelSerializer): - messages = ReadOnlyJSONField(required=False) - - class Meta: - model = ImportTask - fields = ( - 'status', - 'uid', - 'messages', - 'date_created', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - -class ImportTaskListSerializer(ImportTaskSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='importtask-detail' - ) - messages = ReadOnlyJSONField(required=False) - - class Meta(ImportTaskSerializer.Meta): - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - ) - - -class ExportTaskSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='exporttask-detail' - ) - messages = ReadOnlyJSONField(required=False) - data = ReadOnlyJSONField() - - class Meta: - model = ExportTask - fields = ( - 'url', - 'status', - 'messages', - 'uid', - 'date_created', - 'last_submission_time', - 'result', - 'data', - ) - extra_kwargs = { - 'status': { - 'read_only': True, - }, - 'uid': { - 'read_only': True, - }, - 'last_submission_time': { - 'read_only': True, - }, - 'result': { - 'read_only': True, - }, - } - - -class AssetListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - # WARNING! If you're changing something here, please update - # `Asset.optimize_queryset_for_list()`; otherwise, you'll cause an - # additional database query for each asset in the list. - fields = ('url', - 'date_modified', - 'date_created', - 'owner', - 'summary', - 'owner__username', - 'parent', - 'uid', - 'tag_string', - 'settings', - 'kind', - 'name', - 'asset_type', - 'version_id', - 'has_deployment', - 'deployed_version_id', - 'deployment__identifier', - 'deployment__active', - 'deployment__submission_count', - 'permissions', - 'downloads', - ) - - -class AssetUrlListSerializer(AssetSerializer): - class Meta(AssetSerializer.Meta): - fields = ('url',) - - -class UserSerializer(serializers.HyperlinkedModelSerializer): - assets = PaginatedApiField( - serializer_class=AssetUrlListSerializer - ) - - class Meta: - model = User - fields = ('url', - 'username', - 'assets', - 'owned_collections', - ) - extra_kwargs = { - 'url' : { - 'lookup_field': 'username', - }, - 'owned_collections': { - 'lookup_field': 'uid', - }, - } ->>>>>>> WIP - split serializers class CurrentUserSerializer(serializers.ModelSerializer): @@ -935,7 +22,6 @@ class CurrentUserSerializer(serializers.ModelSerializer): date_joined = serializers.SerializerMethodField() projects_url = serializers.SerializerMethodField() gravatar = serializers.SerializerMethodField() - languages = serializers.SerializerMethodField() extra_details = WritableJSONField(source='extra_details.data') current_password = serializers.CharField(write_only=True, required=False) new_password = serializers.CharField(write_only=True, required=False) @@ -955,7 +41,6 @@ class Meta: 'gravatar', 'is_staff', 'last_login', - 'languages', 'extra_details', 'current_password', 'new_password', @@ -977,35 +62,19 @@ def get_projects_url(self, obj): def get_gravatar(self, obj): return gravatar_url(obj.email) - def get_languages(self, obj): - return settings.LANGUAGES - def get_git_rev(self, obj): request = self.context.get('request', False) - if settings.EXPOSE_GIT_REV or (request and request.user.is_superuser): + if constance.config.EXPOSE_GIT_REV or ( + request and request.user.is_superuser + ): return settings.GIT_REV else: return False def to_representation(self, obj): -<<<<<<< HEAD if obj.is_anonymous: return {'message': 'user is not logged in'} rep = super().to_representation(obj) -======= - if obj.is_anonymous(): - return {'message': 'user is not logged in'} - rep = super(CurrentUserSerializer, self).to_representation(obj) - if settings.UPCOMING_DOWNTIME: - # setting is in the format: - # [dateutil.parser.parse('6pm edt').isoformat(), countdown_msg] - rep['upcoming_downtime'] = settings.UPCOMING_DOWNTIME - # TODO: Find a better location for SECTORS and COUNTRIES - # as the functionality develops. (possibly in tags?) - rep['available_sectors'] = SECTORS - rep['available_countries'] = COUNTRIES - rep['all_languages'] = LANGUAGES ->>>>>>> WIP - split serializers if not rep['extra_details']: rep['extra_details'] = {} # `require_auth` needs to be read from KC every time @@ -1015,7 +84,33 @@ def to_representation(self, obj): return rep + def validate(self, attrs): + if self.instance: + + current_password = attrs.pop('current_password', False) + new_password = attrs.get('new_password', False) + + if all((current_password, new_password)): + if not self.instance.check_password(current_password): + raise serializers.ValidationError({ + 'current_password': _('Incorrect current password.') + }) + elif any((current_password, new_password)): + not_empty_field_name = 'current_password' \ + if current_password else 'new_password' + empty_field_name = 'current_password' \ + if new_password else 'new_password' + raise serializers.ValidationError({ + empty_field_name: _('`current_password` and `new_password` ' + 'must both be sent together; ' + f'`{not_empty_field_name}` cannot be ' + 'sent individually.') + }) + + return attrs + def update(self, instance, validated_data): + # "The `.update()` method does not support writable dotted-source # fields by default." --DRF extra_details = validated_data.pop('extra_details', False) @@ -1029,274 +124,14 @@ def update(self, instance, validated_data): instance.pk, extra_details['data']['require_auth']) extra_details_obj.data.update(extra_details['data']) extra_details_obj.save() - current_password = validated_data.pop('current_password', False) - new_password = validated_data.pop('new_password', False) - if all((current_password, new_password)): - with transaction.atomic(): - if instance.check_password(current_password): - instance.set_password(new_password) - instance.save() -<<<<<<< HEAD - request = self.context.get('request', False) - if request: - update_session_auth_hash(request, instance) -======= ->>>>>>> WIP - split serializers - else: - raise serializers.ValidationError({ - 'current_password': 'Incorrect current password.' - }) - elif any((current_password, new_password)): - raise serializers.ValidationError( -<<<<<<< HEAD - 'current_password and new_password must both be sent ' - 'together; one or the other cannot be sent individually.' - ) - return super().update( - instance, validated_data) -======= - 'current_password and new_password must both be sent ' \ - 'together; one or the other cannot be sent individually.' - ) - return super(CurrentUserSerializer, self).update( - instance, validated_data) - -class CreateUserSerializer(serializers.ModelSerializer): - username = serializers.RegexField( - regex=USERNAME_REGEX, - max_length=USERNAME_MAX_LENGTH, - error_messages={'invalid': USERNAME_INVALID_MESSAGE} - ) - email = serializers.EmailField() - class Meta: - model = User - fields = ( - 'username', - 'password', - 'first_name', - 'last_name', - 'email', - #'is_staff', - #'is_superuser', - #'is_active', - ) - extra_kwargs = { - 'password': {'write_only': True}, - 'email': {'required': True} - } - - def create(self, validated_data): - user = User() - user.set_password(validated_data['password']) - non_password_fields = list(self.Meta.fields) - try: - non_password_fields.remove('password') - except ValueError: - pass - for field in non_password_fields: - try: - setattr(user, field, validated_data[field]) - except KeyError: - pass - user.save() - return user - - -class CollectionChildrenSerializer(serializers.Serializer): - def to_representation(self, value): - if isinstance(value, Collection): - serializer = CollectionListSerializer - elif isinstance(value, Asset): - serializer = AssetListSerializer - else: - raise Exception('Unexpected child type {}'.format(type(value))) - return serializer(value, context=self.context).data + new_password = validated_data.get('new_password', False) + if new_password: + instance.set_password(new_password) + instance.save() + request = self.context.get('request', False) + if request: + update_session_auth_hash(request, instance) - -class CollectionSerializer(serializers.HyperlinkedModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', view_name='collection-detail') - owner = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - read_only=True - ) - parent = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - required=False, - view_name='collection-detail', - queryset=Collection.objects.all() - ) - owner__username = serializers.ReadOnlyField(source='owner.username') - # ancestors are ordered from farthest to nearest - ancestors = AncestorCollectionsSerializer( - many=True, read_only=True, source='get_ancestors_or_none') - children = PaginatedApiField( - serializer_class=CollectionChildrenSerializer, - # "The value `source='*'` has a special meaning, and is used to indicate - # that the entire object should be passed through to the field" - # (http://www.django-rest-framework.org/api-guide/fields/#source). - source='*', - source_processor=lambda source: CollectionChildrenQuerySet( - source - ).optimize_for_list() - ) - permissions = ObjectPermissionSerializer(many=True, read_only=True) - downloads = serializers.SerializerMethodField() - tag_string = serializers.CharField(required=False) - access_type = serializers.SerializerMethodField() - - class Meta: - model = Collection - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'owner__username', - 'downloads', - 'date_created', - 'date_modified', - 'ancestors', - 'children', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - lookup_field = 'uid' - extra_kwargs = { - 'assets': { - 'lookup_field': 'uid', - }, - 'uid': { - 'read_only': True, - }, - } - - def _get_tag_names(self, obj): - return obj.tags.names() - - def get_downloads(self, obj): - request = self.context.get('request', None) - obj_url = reverse( - 'collection-detail', args=(obj.uid,), request=request) - return [ - {'format': 'zip', 'url': '%s?format=zip' % obj_url}, - ] - - def get_access_type(self, obj): - try: - request = self.context['request'] - except KeyError: - return None - if request.user == obj.owner: - return 'owned' - # `obj.permissions.filter(...).exists()` would be cleaner, but it'd - # cost a query. This ugly loop takes advantage of having already called - # `prefetch_related()` - for permission in obj.permissions.all(): - if not permission.deny and permission.user == request.user: - return 'shared' - for subscription in obj.usercollectionsubscription_set.all(): - # `usercollectionsubscription_set__user` is not prefetched - if subscription.user_id == request.user.pk: - return 'subscribed' - if obj.discoverable_when_public: - return 'public' - if request.user.is_superuser: - return 'superuser' - raise Exception(u'{} has unexpected access to {}'.format( - request.user.username, obj.uid)) - - -class SitewideMessageSerializer(serializers.ModelSerializer): - class Meta: - model = SitewideMessage - lookup_field = 'slug' - fields = ('slug', - 'body',) - -class CollectionListSerializer(CollectionSerializer): - children_count = serializers.SerializerMethodField() - assets_count = serializers.SerializerMethodField() - - def get_children_count(self, obj): - return obj.children.count() - - def get_assets_count(self, obj): - return Asset.objects.filter(parent=obj).only('pk').count() - return obj.assets.count() - - class Meta(CollectionSerializer.Meta): - fields = ('name', - 'uid', - 'kind', - 'url', - 'parent', - 'owner', - 'children_count', - 'assets_count', - 'owner__username', - 'date_created', - 'date_modified', - 'permissions', - 'access_type', - 'discoverable_when_public', - 'tag_string',) - - -class AuthorizedApplicationUserSerializer(serializers.BaseSerializer): - username = serializers.CharField() - password = serializers.CharField(style={'input_type': 'password'}) - token = serializers.CharField(read_only=True) - def to_internal_value(self, data): - field_names = ('username', 'password') - validation_errors = {} - validated_data = {} - for field_name in field_names: - value = data.get(field_name) - if not value: - validation_errors[field_name] = 'This field is required.' - else: - validated_data[field_name] = value - if len(validation_errors): - raise exceptions.ValidationError(validation_errors) - return validated_data - - -class OneTimeAuthenticationKeySerializer(serializers.ModelSerializer): - username = serializers.SlugRelatedField( - slug_field='username', source='user', queryset=User.objects.all()) - class Meta: - model = OneTimeAuthenticationKey - fields = ('username', 'key', 'expiry') - - -class UserCollectionSubscriptionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='usercollectionsubscription-detail' - ) - collection = RelativePrefixHyperlinkedRelatedField( - lookup_field='uid', - view_name='collection-detail', - queryset=Collection.objects.none() # will be set in __init__() - ) - uid = serializers.ReadOnlyField() - - def __init__(self, *args, **kwargs): - super(UserCollectionSubscriptionSerializer, self).__init__( - *args, **kwargs) - self.fields['collection'].queryset = get_objects_for_user( - get_anonymous_user(), - PERM_VIEW_COLLECTION, - Collection.objects.filter(discoverable_when_public=True) - ) - - class Meta: - model = UserCollectionSubscription - lookup_field = 'uid' - fields = ('url', 'collection', 'uid') ->>>>>>> WIP - split serializers + return super().update( + instance, validated_data) diff --git a/kpi/serializers/v2/object_permission.py b/kpi/serializers/v2/object_permission.py new file mode 100644 index 0000000000..5df635f8b5 --- /dev/null +++ b/kpi/serializers/v2/object_permission.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from rest_framework import serializers + +from kpi.constants import PERM_FROM_KC_ONLY +from kpi.fields import GenericHyperlinkedRelatedField, \ + RelativePrefixHyperlinkedRelatedField +from kpi.models import ObjectPermission + + +class ObjectPermissionSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + lookup_field='uid', + view_name='objectpermission-detail' + ) + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + ) + permission = serializers.SlugRelatedField( + slug_field='codename', + queryset=Permission.objects.all() + ) + content_object = GenericHyperlinkedRelatedField( + lookup_field='uid', + style={'base_template': 'input.html'} # Render as a simple text box + ) + inherited = serializers.ReadOnlyField() + + class Meta: + model = ObjectPermission + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'content_object', + 'deny', + 'inherited', + ) + extra_kwargs = { + 'uid': { + 'read_only': True, + }, + } + + def create(self, validated_data): + content_object = validated_data['content_object'] + user = validated_data['user'] + perm = validated_data['permission'].codename + with transaction.atomic(): + # TEMPORARY Issue #1161: something other than KC is setting a + # permission; clear the `from_kc_only` flag + ObjectPermission.objects.filter( + user=user, + permission__codename=PERM_FROM_KC_ONLY, + object_id=content_object.id, + content_type=ContentType.objects.get_for_model(content_object) + ).delete() + return content_object.assign_perm(user, perm) + + +class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): + ''' + When serializing a list of permissions inside the object to which they are + assigned, omit `content_object` to improve performance significantly + ''' + class Meta(ObjectPermissionSerializer.Meta): + fields = ( + 'uid', + 'kind', + 'url', + 'user', + 'permission', + 'deny', + 'inherited', + ) diff --git a/kpi/utils/url_helper.py b/kpi/utils/url_helper.py new file mode 100644 index 0000000000..a63fd8ced7 --- /dev/null +++ b/kpi/utils/url_helper.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from rest_framework.reverse import reverse, reverse_lazy + + +class UrlHelper: + + def __init__(self): + pass + + @staticmethod + def reverse(viewname, *args, **kwargs): + """ + Has the same behavior as `rest_framework.reverse.reverse`, + except it adds namespace if needed. + + :param viewname: str + :param namespace: str + :return: str + """ + context = kwargs.pop('context', None) + namespace = None + + if context: + try: + namespace = context.get('view').URL_NAMESPACE + except AttributeError: + pass + + if namespace is not None: + viewname = '{namespace}:{viewname}'.format( + namespace=namespace, + viewname=viewname + ) + + return reverse(viewname, *args, **kwargs) + + @staticmethod + def reverse_lazy(viewname, *args, **kwargs): + """ + Has the same behavior as `rest_framework.reverse.reverse_lazy`, + except it adds namespace if needed. + + :param viewname: str + :param namespace: str + :return: str + """ + context = kwargs.get('context') + namespace = None + if context: + namespace = getattr(context.get('view'), 'URL_NAMESPACE') + + if namespace is not None: + viewname = '{namespace}:{viewname}'.format( + namespace=namespace, + viewname=viewname + ) + + return reverse_lazy(viewname, *args, **kwargs) + diff --git a/kpi/views/v1/asset.py b/kpi/views/v1/asset.py index 7e59a9b2d1..0f723efa45 100644 --- a/kpi/views/v1/asset.py +++ b/kpi/views/v1/asset.py @@ -1,11 +1,10 @@ -<<<<<<< HEAD # coding: utf-8 from django.shortcuts import get_object_or_404 from rest_framework import exceptions, renderers, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from kpi.constants import CLONE_ARG_NAME, PERM_SHARE_ASSET, PERM_VIEW_ASSET +from kpi.constants import CLONE_ARG_NAME, PERM_MANAGE_ASSET, PERM_VIEW_ASSET from kpi.models import Asset from kpi.serializers.v1.asset import AssetSerializer, AssetListSerializer from kpi.views.v2.asset import AssetViewSet as AssetViewSetV2 @@ -14,104 +13,43 @@ class AssetViewSet(AssetViewSetV2): """ ## This document is for a deprecated version of kpi's API. - **Please upgrade to latest release `/api/v2/assets/`** - - -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -import copy -import json -from hashlib import md5 - -from django.http import Http404 -from django.shortcuts import get_object_or_404 -from rest_framework import exceptions, renderers, status, viewsets -from rest_framework.decorators import detail_route, list_route -from rest_framework.response import Response -from rest_framework_extensions.mixins import NestedViewSetMixin - -from kpi.constants import ASSET_TYPES, ASSET_TYPE_ARG_NAME, ASSET_TYPE_SURVEY, \ - ASSET_TYPE_TEMPLATE, CLONE_ARG_NAME, CLONE_COMPATIBLE_TYPES, \ - CLONE_FROM_VERSION_ID_ARG_NAME, PERM_SHARE_ASSET, PERM_VIEW_ASSET -from kpi.deployment_backends.backends import DEPLOYMENT_BACKENDS -from kpi.exceptions import BadAssetTypeException -from kpi.filters import KpiObjectPermissionsFilter, SearchFilter -from kpi.highlighters import highlight_xform -from kpi.models import Asset -from kpi.models.object_permission import get_anonymous_user, get_objects_for_user -from kpi.permissions import IsOwnerOrReadOnly, PostMappedToChangePermission, \ - get_perm_name -from kpi.renderers import AssetJsonRenderer, SSJsonRenderer, XFormRenderer, \ - XlsRenderer -from kpi.serializers import AssetListSerializer, AssetSerializer, DeploymentSerializer -from kpi.utils.kobo_to_xlsform import to_xlsform_structure -from kpi.utils.ss_structure_to_mdtable import ss_structure_to_mdtable - - -class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): - """ ->>>>>>> Moved current views and serializers under 'v1' parent folder * Assign a asset to a collection partially implemented * Run a partial update of a asset TODO - TODO Complete documentation - ## List of asset endpoints - Lists the asset endpoints accessible to requesting user, for anonymous access a list of public data endpoints is returned. -
     GET /assets/
     
- > Example > > curl -X GET https://[kpi-url]/assets/ - -<<<<<<< HEAD Get a hash of all `version_id`s of assets. -======= - Get an hash of all `version_id`s of assets. ->>>>>>> Moved current views and serializers under 'v1' parent folder Useful to detect any changes in assets with only one call to `API` -
     GET /assets/hash/
     
- > Example > > curl -X GET https://[kpi-url]/assets/hash/ - ## CRUD - * `uid` - is the unique identifier of a specific asset - Retrieves current asset
     GET /assets/{uid}/
     
- - > Example > > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/ - Creates or clones an asset.
     POST /assets/
     
- - > Example > > curl -X POST https://[kpi-url]/assets/ - - > **Payload to create a new asset** > > { @@ -124,7 +62,6 @@ class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): > }, > "asset_type": {string} > } - > **Payload to clone an asset** > > { @@ -132,449 +69,86 @@ class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): > "name": {string}, > "asset_type": {string} > } - where `asset_type` must be one of these values: - * block (can be cloned to `block`, `question`, `survey`, `template`) * question (can be cloned to `question`, `survey`, `template`) * survey (can be cloned to `block`, `question`, `survey`, `template`) * template (can be cloned to `survey`, `template`) - Settings are cloned only when type of assets are `survey` or `template`. In that case, `share-metadata` is not preserved. - When creating a new `block` or `question` asset, settings are not saved either. - ### Deployment - Retrieves the existing deployment, if any.
     GET /assets/{uid}/deployment
     
- > Example > > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - Creates a new deployment, but only if a deployment does not exist already.
     POST /assets/{uid}/deployment
     
- > Example > > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - Updates the `active` field of the existing deployment.
     PATCH /assets/{uid}/deployment
     
- > Example > > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - Overwrites the entire deployment, including the form contents, but does not change the deployment's identifier
     PUT /assets/{uid}/deployment
     
- > Example > > curl -X PUT https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/deployment - - ### Permissions Updates permissions of the specific asset
     PATCH /assets/{uid}/permissions
     
- > Example > > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions - ### CURRENT ENDPOINT """ -<<<<<<< HEAD -======= - # Filtering handled by KpiObjectPermissionsFilter.filter_queryset() - queryset = Asset.objects.all() - - serializer_class = AssetSerializer - lookup_field = 'uid' - permission_classes = (IsOwnerOrReadOnly,) - filter_backends = (KpiObjectPermissionsFilter, SearchFilter) - - renderer_classes = (renderers.BrowsableAPIRenderer, - AssetJsonRenderer, - SSJsonRenderer, - XFormRenderer, - XlsRenderer, - ) - ->>>>>>> Moved current views and serializers under 'v1' parent folder def get_serializer_class(self): if self.action == 'list': return AssetListSerializer else: return AssetSerializer -<<<<<<< HEAD def get_serializer_context(self): return super(AssetViewSetV2, self).get_serializer_context() - @action(detail=True, methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) -======= - def get_queryset(self, *args, **kwargs): - queryset = super(AssetViewSet, self).get_queryset(*args, **kwargs) - if self.action == 'list': - return queryset.model.optimize_queryset_for_list(queryset) - else: - # This is called to retrieve an individual record. How much do we - # have to care about optimizations for that? - return queryset - - def _get_clone_serializer(self, current_asset=None): - """ - Gets the serializer from cloned object - :param current_asset: Asset. Asset to be updated. - :return: AssetSerializer - """ - original_uid = self.request.data[CLONE_ARG_NAME] - original_asset = get_object_or_404(Asset, uid=original_uid) - if CLONE_FROM_VERSION_ID_ARG_NAME in self.request.data: - original_version_id = self.request.data.get(CLONE_FROM_VERSION_ID_ARG_NAME) - source_version = get_object_or_404( - original_asset.asset_versions, uid=original_version_id) - else: - source_version = original_asset.asset_versions.first() - - view_perm = get_perm_name('view', original_asset) - if not self.request.user.has_perm(view_perm, original_asset): - raise Http404 - - partial_update = isinstance(current_asset, Asset) - cloned_data = self._prepare_cloned_data(original_asset, source_version, partial_update) - if partial_update: - return self.get_serializer(current_asset, data=cloned_data, partial=True) - else: - return self.get_serializer(data=cloned_data) - - def _prepare_cloned_data(self, original_asset, source_version, partial_update): - """ - Some business rules must be applied when cloning an asset to another with a different type. - It prepares the data to be cloned accordingly. - - It raises an exception if source and destination are not compatible for cloning. - - :param original_asset: Asset - :param source_version: AssetVersion - :param partial_update: Boolean - :return: dict - """ - if self._validate_destination_type(original_asset): - # `to_clone_dict()` returns only `name`, `content`, `asset_type`, - # and `tag_string` - cloned_data = original_asset.to_clone_dict(version=source_version) - - # Allow the user's request data to override `cloned_data` - cloned_data.update(self.request.data.items()) - - if partial_update: - # Because we're updating an asset from another which can have another type, - # we need to remove `asset_type` from clone data to ensure it's not updated - # when serializer is initialized. - cloned_data.pop("asset_type", None) - else: - # Change asset_type if needed. - cloned_data["asset_type"] = self.request.data.get(ASSET_TYPE_ARG_NAME, original_asset.asset_type) - - cloned_asset_type = cloned_data.get("asset_type") - # Settings are: Country, Description, Sector and Share-metadata - # Copy settings only when original_asset is `survey` or `template` - # and `asset_type` property of `cloned_data` is `survey` or `template` - # or None (partial_update) - if cloned_asset_type in [None, ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY] and \ - original_asset.asset_type in [ASSET_TYPE_TEMPLATE, ASSET_TYPE_SURVEY]: - - settings = original_asset.settings.copy() - settings.pop("share-metadata", None) - - cloned_data_settings = cloned_data.get("settings", {}) - - # Depending of the client payload. settings can be JSON or string. - # if it's a string. Let's load it to be able to merge it. - if not isinstance(cloned_data_settings, dict): - cloned_data_settings = json.loads(cloned_data_settings) - - settings.update(cloned_data_settings) - cloned_data['settings'] = json.dumps(settings) - - # until we get content passed as a dict, transform the content obj to a str - # TODO, verify whether `Asset.content.settings.id_string` should be cleared out. - cloned_data["content"] = json.dumps(cloned_data.get("content")) - return cloned_data - else: - raise BadAssetTypeException("Destination type is not compatible with source type") - - def _validate_destination_type(self, original_asset_): - """ - Validates if destination asset can be cloned from source asset. - :param original_asset_ Asset: Source - :return: Boolean - """ - is_valid = True - - if CLONE_ARG_NAME in self.request.data and ASSET_TYPE_ARG_NAME in self.request.data: - destination_type = self.request.data.get(ASSET_TYPE_ARG_NAME) - if destination_type in dict(ASSET_TYPES).values(): - source_type = original_asset_.asset_type - is_valid = destination_type in CLONE_COMPATIBLE_TYPES.get(source_type) - else: - is_valid = False - - return is_valid - - def create(self, request, *args, **kwargs): - if CLONE_ARG_NAME in request.data: - serializer = self._get_clone_serializer() - else: - serializer = self.get_serializer(data=request.data) - - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - - @list_route(methods=["GET"], renderer_classes=[renderers.JSONRenderer]) - def hash(self, request): - """ - Creates an hash of `version_id` of all accessible assets by the user. - Useful to detect changes between each request. - - :param request: - :return: JSON - """ - user = self.request.user - if user.is_anonymous(): - raise exceptions.NotAuthenticated() - else: - accessible_assets = get_objects_for_user( - user, "view_asset", Asset).filter(asset_type=ASSET_TYPE_SURVEY) \ - .order_by("uid") - - assets_version_ids = [asset.version_id for asset in accessible_assets if asset.version_id is not None] - # Sort alphabetically - assets_version_ids.sort() - - if len(assets_version_ids) > 0: - hash = md5("".join(assets_version_ids)).hexdigest() - else: - hash = "" - - return Response({ - "hash": hash - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.content', - 'uid': asset.uid, - 'data': asset.to_ss_structure(), - }) - - @detail_route(renderer_classes=[renderers.JSONRenderer]) - def valid_content(self, request, uid): - asset = self.get_object() - return Response({ - 'kind': 'asset.valid_content', - 'uid': asset.uid, - 'data': to_xlsform_structure(asset.content), - }) - - @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer]) - def koboform(self, request, *args, **kwargs): - asset = self.get_object() - return Response({'asset': asset, }, template_name='koboform.html') - - @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) - def table_view(self, request, *args, **kwargs): - sa = self.get_object() - md_table = ss_structure_to_mdtable(sa.ordered_xlsform_content()) - return Response('\n' - '
' + md_table.strip())
-
-    @detail_route(renderer_classes=[renderers.StaticHTMLRenderer])
-    def xls(self, request, *args, **kwargs):
-        return self.table_view(self, request, *args, **kwargs)
-
-    @detail_route(renderer_classes=[renderers.TemplateHTMLRenderer])
-    def xform(self, request, *args, **kwargs):
-        asset = self.get_object()
-        export = asset._snapshot(regenerate=True)
-        # TODO-- forward to AssetSnapshotViewset.xform
-        response_data = copy.copy(export.details)
-        options = {
-            'linenos': True,
-            'full': True,
-        }
-        if export.xml != '':
-            response_data['highlighted_xform'] = highlight_xform(export.xml, **options)
-        return Response(response_data, template_name='highlighted_xform.html')
-
-    @detail_route(
-        methods=['get', 'post', 'patch'],
-        permission_classes=[PostMappedToChangePermission]
+    @action(
+        detail=True,
+        methods=["PATCH"],
+        renderer_classes=[renderers.JSONRenderer],
     )
-    def deployment(self, request, uid):
-        """
-        A GET request retrieves the existing deployment, if any.
-        A POST request creates a new deployment, but only if a deployment does
-            not exist already.
-        A PATCH request updates the `active` field of the existing deployment.
-        A PUT request overwrites the entire deployment, including the form
-            contents, but does not change the deployment's identifier
-        """
-        asset = self.get_object()
-        serializer_context = self.get_serializer_context()
-        serializer_context['asset'] = asset
-
-        # TODO: Require the client to provide a fully-qualified identifier,
-        # otherwise provide less kludgy solution
-        if 'identifier' not in request.data and 'id_string' in request.data:
-            id_string = request.data.pop('id_string')[0]
-            backend_name = request.data['backend']
-            try:
-                backend = DEPLOYMENT_BACKENDS[backend_name]
-            except KeyError:
-                raise KeyError(
-                    'cannot retrieve asset backend: "{}"'.format(backend_name))
-            request.data['identifier'] = backend.make_identifier(
-                request.user.username, id_string)
-
-        if request.method == 'GET':
-            if not asset.has_deployment:
-                raise Http404
-            else:
-                serializer = DeploymentSerializer(
-                    asset.deployment, context=serializer_context
-                )
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-        elif request.method == 'POST':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use PATCH to update an existing deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    data=request.data,
-                    context=serializer_context
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-        elif request.method == 'PATCH':
-            if not asset.can_be_deployed:
-                raise BadAssetTypeException("Only surveys may be deployed, but this asset is a {}".format(
-                    asset.asset_type))
-            else:
-                if not asset.has_deployment:
-                    raise exceptions.MethodNotAllowed(
-                        method=request.method,
-                        detail='Use POST to create a new deployment'
-                    )
-                serializer = DeploymentSerializer(
-                    asset.deployment,
-                    data=request.data,
-                    context=serializer_context,
-                    partial=True
-                )
-                serializer.is_valid(raise_exception=True)
-                serializer.save()
-                # TODO: Understand why this 404s when `serializer.data` is not
-                # coerced to a dict
-                return Response(dict(serializer.data))
-
-    @detail_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer])
->>>>>>> Moved current views and serializers under 'v1' parent folder
     def permissions(self, request, uid):
         target_asset = self.get_object()
-        source_asset = get_object_or_404(Asset, uid=request.data.get(CLONE_ARG_NAME))
+        source_asset = get_object_or_404(
+            Asset, uid=request.data.get(CLONE_ARG_NAME)
+        )
         user = request.user
         response = {}
         http_status = status.HTTP_204_NO_CONTENT
 
-        if user.has_perm(PERM_SHARE_ASSET, target_asset) and \
-                user.has_perm(PERM_VIEW_ASSET, source_asset):
+        if user.has_perm(PERM_MANAGE_ASSET, target_asset) and user.has_perm(
+            PERM_VIEW_ASSET, source_asset
+        ):
             if not target_asset.copy_permissions_from(source_asset):
                 http_status = status.HTTP_400_BAD_REQUEST
-                response = {"detail": "Source and destination objects don't seem to have the same type"}
+                response = {
+                    "detail": "Source and destination objects don't seem to have the same type"
+                }
         else:
             raise exceptions.PermissionDenied()
 
         return Response(response, status=http_status)
-<<<<<<< HEAD
-=======
-
-    def perform_create(self, serializer):
-        # Check if the user is anonymous. The
-        # django.contrib.auth.models.AnonymousUser object doesn't work for
-        # queries.
-        user = self.request.user
-        if user.is_anonymous():
-            user = get_anonymous_user()
-        serializer.save(owner=user)
-
-    def partial_update(self, request, *args, **kwargs):
-        instance = self.get_object()
-
-        if CLONE_ARG_NAME in request.data:
-            serializer = self._get_clone_serializer(instance)
-        else:
-            serializer = self.get_serializer(instance, data=request.data, partial=True)
-
-        serializer.is_valid(raise_exception=True)
-        self.perform_update(serializer)
-        return Response(serializer.data)
-
-    def perform_destroy(self, instance):
-        if hasattr(instance, 'has_deployment') and instance.has_deployment:
-            instance.deployment.delete()
-        return super(AssetViewSet, self).perform_destroy(instance)
-
-    def finalize_response(self, request, response, *args, **kwargs):
-        """ Manipulate the headers as appropriate for the requested format.
-        See https://github.com/tomchristie/django-rest-framework/issues/1041#issuecomment-22709658.
-        """
-        # If the request fails at an early stage, e.g. the user has no
-        # model-level permissions, accepted_renderer won't be present.
-        if hasattr(request, 'accepted_renderer'):
-            # Check the class of the renderer instead of just looking at the
-            # format, because we don't want to set Content-Disposition:
-            # attachment on asset snapshot XML
-            if (isinstance(request.accepted_renderer, XlsRenderer) or
-                    isinstance(request.accepted_renderer, XFormRenderer)):
-                response[
-                    'Content-Disposition'
-                ] = 'attachment; filename={}.{}'.format(
-                    self.get_object().uid,
-                    request.accepted_renderer.format
-                )
-
-        return super(AssetViewSet, self).finalize_response(
-            request, response, *args, **kwargs)
->>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v1/collection.py b/kpi/views/v1/collection.py
index 4a6fdff01a..3b6efab789 100644
--- a/kpi/views/v1/collection.py
+++ b/kpi/views/v1/collection.py
@@ -1,4 +1,3 @@
-<<<<<<< HEAD
 # coding: utf-8
 from kpi.serializers import CollectionSerializer, CollectionListSerializer
 from kpi.views.v2.collection import CollectionViewSet as CollectionViewSetV2
@@ -10,105 +9,6 @@ class CollectionViewSet(CollectionViewSetV2):
 
     **Please upgrade to latest release `/api/v2/collections/`**
     """
-=======
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.forms import model_to_dict
-from django.http import Http404
-from django.shortcuts import get_object_or_404
-from rest_framework import viewsets, status, exceptions
-from rest_framework.response import Response
-from kpi.filters import KpiObjectPermissionsFilter, SearchFilter
-from kpi.models import Collection
-from kpi.model_utils import disable_auto_field_update
-from kpi.permissions import IsOwnerOrReadOnly, get_perm_name
-from kpi.serializers import CollectionSerializer, CollectionListSerializer
-from kpi.constants import CLONE_ARG_NAME, COLLECTION_CLONE_FIELDS
-
-
-class CollectionViewSet(viewsets.ModelViewSet):
-    # Filtering handled by KpiObjectPermissionsFilter.filter_queryset()
-    queryset = Collection.objects.select_related(
-        'owner', 'parent'
-    ).prefetch_related(
-        'permissions',
-        'permissions__permission',
-        'permissions__user',
-        'permissions__content_object',
-        'usercollectionsubscription_set',
-    ).all().order_by('-date_modified')
-    serializer_class = CollectionSerializer
-    permission_classes = (IsOwnerOrReadOnly,)
-    filter_backends = (KpiObjectPermissionsFilter, SearchFilter)
-    lookup_field = 'uid'
-
-    def _clone(self):
-        # Clone an existing collection.
-        original_uid = self.request.data[CLONE_ARG_NAME]
-        original_collection = get_object_or_404(Collection, uid=original_uid)
-        view_perm = get_perm_name('view', original_collection)
-        if not self.request.user.has_perm(view_perm, original_collection):
-            raise Http404
-        else:
-            # Copy the essential data from the original collection.
-            original_data= model_to_dict(original_collection)
-            cloned_data= {keep_field: original_data[keep_field]
-                          for keep_field in COLLECTION_CLONE_FIELDS}
-            if original_collection.tag_string:
-                cloned_data['tag_string']= original_collection.tag_string
-
-            # Pull any additionally provided parameters/overrides from the
-            # request.
-            for param in self.request.data:
-                cloned_data[param] = self.request.data[param]
-            serializer = self.get_serializer(data=cloned_data)
-            serializer.is_valid(raise_exception=True)
-            self.perform_create(serializer)
-
-            headers = self.get_success_headers(serializer.data)
-            return Response(serializer.data, status=status.HTTP_201_CREATED,
-                            headers=headers)
-
-    def create(self, request, *args, **kwargs):
-        if CLONE_ARG_NAME not in request.data:
-            return super(CollectionViewSet, self).create(request, *args,
-                                                         **kwargs)
-        else:
-            return self._clone()
-
-    def perform_create(self, serializer):
-        serializer.save(owner=self.request.user)
-
-    def perform_update(self, serializer, *args, **kwargs):
-        """ Only the owner is allowed to change `discoverable_when_public` """
-        original_collection = self.get_object()
-        if (self.request.user != original_collection.owner and
-                'discoverable_when_public' in serializer.validated_data and
-                (serializer.validated_data['discoverable_when_public'] !=
-                    original_collection.discoverable_when_public)
-        ):
-            raise exceptions.PermissionDenied()
-
-        # Some fields shouldn't affect the modification date
-        FIELDS_NOT_AFFECTING_MODIFICATION_DATE = set((
-            'discoverable_when_public',
-        ))
-        changed_fields = set()
-        for k, v in serializer.validated_data.iteritems():
-            if getattr(original_collection, k) != v:
-                changed_fields.add(k)
-        if changed_fields.issubset(FIELDS_NOT_AFFECTING_MODIFICATION_DATE):
-            with disable_auto_field_update(Collection, 'date_modified'):
-                return super(CollectionViewSet, self).perform_update(
-                    serializer, *args, **kwargs)
-
-        return super(CollectionViewSet, self).perform_update(
-                serializer, *args, **kwargs)
-
-    def perform_destroy(self, instance):
-        instance.delete_with_deferred_indexing()
->>>>>>> Moved current views and serializers under 'v1' parent folder
 
     def get_serializer_class(self):
         if self.action == 'list':
diff --git a/kpi/views/v1/user.py b/kpi/views/v1/user.py
index a6f68f0c54..0fe4925140 100644
--- a/kpi/views/v1/user.py
+++ b/kpi/views/v1/user.py
@@ -1,4 +1,4 @@
-<<<<<<< HEAD
+
 # coding: utf-8
 from kpi.serializers import UserSerializer
 from kpi.views.v2.user import UserViewSet as UserViewSetV2
@@ -7,38 +7,9 @@
 class UserViewSet(UserViewSetV2):
     """
     ## This document is for a deprecated version of kpi's API.
-
     **Please upgrade to latest release `/api/v2/users/`**
-
     This viewset provides only the `detail` action; `list` is *not* provided to
     avoid disclosing every username in the database
     """
 
     serializer_class = UserSerializer
-=======
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from django.contrib.auth.models import User
-from rest_framework import exceptions, mixins, viewsets
-
-from kpi.models.authorized_application import ApplicationTokenAuthentication
-from kpi.serializers import UserSerializer
-
-
-class UserViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin):
-    """
-    This viewset provides only the `detail` action; `list` is *not* provided to
-    avoid disclosing every username in the database
-    """
-    queryset = User.objects.all()
-    serializer_class = UserSerializer
-    lookup_field = 'username'
-
-    def __init__(self, *args, **kwargs):
-        super(UserViewSet, self).__init__(*args, **kwargs)
-        self.authentication_classes += [ApplicationTokenAuthentication]
-
-    def list(self, request, *args, **kwargs):
-        raise exceptions.PermissionDenied()
->>>>>>> Moved current views and serializers under 'v1' parent folder
diff --git a/kpi/views/v2/collection.py b/kpi/views/v2/collection.py
index 29eb8d80e7..88b26b6226 100644
--- a/kpi/views/v2/collection.py
+++ b/kpi/views/v2/collection.py
@@ -4,7 +4,6 @@
 from django.shortcuts import get_object_or_404
 from rest_framework import viewsets, status, exceptions
 from rest_framework.response import Response
-
 from kpi.filters import KpiObjectPermissionsFilter, SearchFilter
 from kpi.models import Collection
 from kpi.model_utils import disable_auto_field_update

From 6f8261ce035270821c2f8392b75d3a3ab46994c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Olivier=20L=C3=A9ger?= 
Date: Tue, 7 May 2019 16:34:11 -0400
Subject: [PATCH 038/499] Added AssetVersion to v2, applied same logic for
 Asset in v1

---
 kpi/serializers/v1/asset_version.py     |  2 ++
 kpi/serializers/v2/asset_version.py     |  2 +-
 kpi/serializers/v2/object_permission.py |  2 +-
 kpi/views/v1/asset_version.py           | 41 ++-----------------------
 4 files changed, 6 insertions(+), 41 deletions(-)

diff --git a/kpi/serializers/v1/asset_version.py b/kpi/serializers/v1/asset_version.py
index 27e8c31dc1..3ec5df710e 100644
--- a/kpi/serializers/v1/asset_version.py
+++ b/kpi/serializers/v1/asset_version.py
@@ -7,6 +7,8 @@
 class AssetVersionListSerializer(AssetVersionListSerializerV2):
     pass
 
+class AssetVersionListSerializer(AssetVersionListSerializerV2):
+    pass
 
 class AssetVersionSerializer(AssetVersionSerializerV2):
     pass
diff --git a/kpi/serializers/v2/asset_version.py b/kpi/serializers/v2/asset_version.py
index 57d080e101..25952a8f06 100644
--- a/kpi/serializers/v2/asset_version.py
+++ b/kpi/serializers/v2/asset_version.py
@@ -1,8 +1,8 @@
 # coding: utf-8
 from rest_framework import serializers
-from rest_framework.reverse import reverse
 
 from kpi.models import AssetVersion
+from kpi.utils.url_helper import UrlHelper
 
 
 class AssetVersionListSerializer(serializers.Serializer):
diff --git a/kpi/serializers/v2/object_permission.py b/kpi/serializers/v2/object_permission.py
index 5df635f8b5..7f9dbb74ee 100644
--- a/kpi/serializers/v2/object_permission.py
+++ b/kpi/serializers/v2/object_permission.py
@@ -29,7 +29,7 @@ class ObjectPermissionSerializer(serializers.ModelSerializer):
     )
     content_object = GenericHyperlinkedRelatedField(
         lookup_field='uid',
-        style={'base_template': 'input.html'} # Render as a simple text box
+        style={'base_template': 'input.html'}  # Render as a simple text box
     )
     inherited = serializers.ReadOnlyField()
 
diff --git a/kpi/views/v1/asset_version.py b/kpi/views/v1/asset_version.py
index ee6fc35b18..fbf68b84b1 100644
--- a/kpi/views/v1/asset_version.py
+++ b/kpi/views/v1/asset_version.py
@@ -1,4 +1,4 @@
-<<<<<<< HEAD
+
 # coding: utf-8
 from kpi.serializers import AssetVersionListSerializer, AssetVersionSerializer
 from kpi.views.v2.asset_version import \
@@ -8,48 +8,11 @@
 class AssetVersionViewSet(AssetVersionViewSetV2):
     """
     ## This document is for a deprecated version of kpi's API.
-
     **Please upgrade to latest release `/api/v2/assets/{uid}/versions`**
     """
-=======
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals, absolute_import
-
-from rest_framework import viewsets
-from rest_framework_extensions.mixins import NestedViewSetMixin
-from kpi.filters import AssetOwnerFilterBackend
-from kpi.models import AssetVersion
-from kpi.serializers import AssetVersionListSerializer, AssetVersionSerializer
-
-
-class AssetVersionViewSet(NestedViewSetMixin, viewsets.ModelViewSet):
-    model = AssetVersion
-    lookup_field = 'uid'
-    filter_backends = (
-            AssetOwnerFilterBackend,
-        )
->>>>>>> Moved current views and serializers under 'v1' parent folder
 
     def get_serializer_class(self):
         if self.action == 'list':
             return AssetVersionListSerializer
         else:
-            return AssetVersionSerializer
-<<<<<<< HEAD
-=======
-
-    def get_queryset(self):
-        _asset_uid = self.get_parents_query_dict()['asset']
-        _deployed = self.request.query_params.get('deployed', None)
-        _queryset = self.model.objects.filter(asset__uid=_asset_uid)
-        if _deployed is not None:
-            _queryset = _queryset.filter(deployed=_deployed)
-        if self.action == 'list':
-            # Save time by only retrieving fields from the DB that the
-            # serializer will use
-            _queryset = _queryset.only(
-                'uid', 'deployed', 'date_modified', 'asset_id')
-        # `AssetVersionListSerializer.get_url()` asks for the asset UID
-        _queryset = _queryset.select_related('asset__uid')
-        return _queryset
->>>>>>> Moved current views and serializers under 'v1' parent folder
+            return

From de5808c2d2c0ac9f03355c71dfa67f8f647ad3c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Olivier=20L=C3=A9ger?= 
Date: Tue, 7 May 2019 16:44:21 -0400
Subject: [PATCH 039/499] Added HookSignal to v2

---
 kpi/views/v1/hook_signal.py | 61 ++++-----------------------
 kpi/views/v2/hook_signal.py | 83 +++++++++++++++++++++++++++++++++++++
 2 files changed, 91 insertions(+), 53 deletions(-)
 create mode 100644 kpi/views/v2/hook_signal.py

diff --git a/kpi/views/v1/hook_signal.py b/kpi/views/v1/hook_signal.py
index a4a8691a90..eba5456946 100644
--- a/kpi/views/v1/hook_signal.py
+++ b/kpi/views/v1/hook_signal.py
@@ -1,20 +1,16 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals, absolute_import
 
-from django.shortcuts import get_object_or_404
-from django.utils.translation import ugettext_lazy as _
-from rest_framework import status, viewsets
-from rest_framework.response import Response
-from rest_framework_extensions.mixins import NestedViewSetMixin
+from kpi.views.v2.hook_signal import HookSignalViewSet as HookSignalViewSetV2
 
-from kobo.apps.hook.utils import HookUtils
-from kpi.models import Asset
-from kpi.utils.log import logging
 
-
-class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet):
+class HookSignalViewSet(HookSignalViewSetV2):
     """
-    ##
+    ## This document is for a deprecated version of kpi's API.
+
+    **Please upgrade to latest release `/api/v2/collections/`**
+
+
     This endpoint is only used to trigger asset's hooks if any.
 
     Tells the hooks to post an instance to external servers.
@@ -35,46 +31,5 @@ class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet):
     >        }
 
     """
-    parent_model = Asset
-
-    def create(self, request, *args, **kwargs):
-        """
-        It's only used to trigger hook services of the Asset (so far).
-
-        :param request:
-        :return:
-        """
-        # Follow Open Rosa responses by default
-        response_status_code = status.HTTP_202_ACCEPTED
-        response = {
-            "detail": _(
-                "We got and saved your data, but may not have fully processed it. You should not try to resubmit.")
-        }
-        try:
-            asset_uid = self.get_parents_query_dict().get("asset")
-            asset = get_object_or_404(self.parent_model, uid=asset_uid)
-            instance_id = request.data.get("instance_id")
-            instance = asset.deployment.get_submission(instance_id)
-
-            # Check if instance really belongs to Asset.
-            if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id):
-                response_status_code = status.HTTP_404_NOT_FOUND
-                response = {
-                    "detail": _("Resource not found")
-                }
-
-            elif not HookUtils.call_services(asset, instance_id):
-                response_status_code = status.HTTP_409_CONFLICT
-                response = {
-                    "detail": _(
-                        "Your data for instance {} has been already submitted.".format(instance_id))
-                }
-
-        except Exception as e:
-            logging.error("HookSignalViewSet.create - {}".format(str(e)))
-            response = {
-                "detail": _("An error has occurred when calling the external service. Please retry later.")
-            }
-            response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
 
-        return Response(response, status=response_status_code)
+    URL_NAMESPACE = None
diff --git a/kpi/views/v2/hook_signal.py b/kpi/views/v2/hook_signal.py
new file mode 100644
index 0000000000..bb112799b2
--- /dev/null
+++ b/kpi/views/v2/hook_signal.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals, absolute_import
+
+from django.shortcuts import get_object_or_404
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import status, viewsets
+from rest_framework.response import Response
+from rest_framework_extensions.mixins import NestedViewSetMixin
+
+from kobo.apps.hook.utils import HookUtils
+from kpi.models import Asset
+from kpi.utils.log import logging
+
+
+class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet):
+    """
+    ##
+    This endpoint is only used to trigger asset's hooks if any.
+
+    Tells the hooks to post an instance to external servers.
+    
+    POST /assets/{uid}/hook-signal/
+    
+ + + > Example + > + > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ + + + > **Expected payload** + > + > { + > "instance_id": {integer} + > } + + """ + + URL_NAMESPACE = 'api_v2' + + parent_model = Asset + + def create(self, request, *args, **kwargs): + """ + It's only used to trigger hook services of the Asset (so far). + + :param request: + :return: + """ + # Follow Open Rosa responses by default + response_status_code = status.HTTP_202_ACCEPTED + response = { + "detail": _( + "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") + } + try: + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(self.parent_model, uid=asset_uid) + instance_id = request.data.get("instance_id") + instance = asset.deployment.get_submission(instance_id) + + # Check if instance really belongs to Asset. + if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + response_status_code = status.HTTP_404_NOT_FOUND + response = { + "detail": _("Resource not found") + } + + elif not HookUtils.call_services(asset, instance_id): + response_status_code = status.HTTP_409_CONFLICT + response = { + "detail": _( + "Your data for instance {} has been already submitted.".format(instance_id)) + } + + except Exception as e: + logging.error("HookSignalViewSet.create - {}".format(str(e))) + response = { + "detail": _("An error has occurred when calling the external service. Please retry later.") + } + response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + return Response(response, status=response_status_code) From ffd75bf7edab1b3f6b0508b6f27686e545a064b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 8 May 2019 11:28:11 -0400 Subject: [PATCH 040/499] WIP - Used 'data' instead of 'submissions' in v2, prepare 'hook' viewsets to v2 --- kpi/views/v1/submission.py | 156 +------------------------------------ 1 file changed, 1 insertion(+), 155 deletions(-) diff --git a/kpi/views/v1/submission.py b/kpi/views/v1/submission.py index 64407b3f1a..0cc0853024 100644 --- a/kpi/views/v1/submission.py +++ b/kpi/views/v1/submission.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD # coding: utf-8 from rest_framework.response import Response @@ -8,159 +7,100 @@ class SubmissionViewSet(DataViewSet): """ ## This document is for a deprecated version of kpi's API. - **Please upgrade to latest release `/api/v2/assets/{uid}/data/`** - -======= -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from django.http import Http404 -from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext_lazy as _ -from rest_framework import renderers, viewsets -from rest_framework.decorators import detail_route, list_route -from rest_framework.response import Response -from rest_framework_extensions.mixins import NestedViewSetMixin - -from kpi.models import Asset -from kpi.permissions import SubmissionPermission -from kpi.renderers import SubmissionXMLRenderer - - -class SubmissionViewSet(NestedViewSetMixin, viewsets.ViewSet): - """ ->>>>>>> Moved current views and serializers under 'v1' parent folder ## List of submissions for a specific asset -
     GET /assets/{asset_uid}/submissions/
     
- By default, JSON format is used but XML format can be used too.
     GET /assets/{asset_uid}/submissions.xml
     GET /assets/{asset_uid}/submissions.json
     
- or -
     GET /assets/{asset_uid}/submissions/?format=xml
     GET /assets/{asset_uid}/submissions/?format=json
     
- > Example > > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/ - ## CRUD - * `uid` - is the unique identifier of a specific asset * `id` - is the unique identifier of a specific submission - **It's not allowed to create submissions with `kpi`'s API** - Retrieves current submission
     GET /assets/{uid}/submissions/{id}/
     
- It's also possible to specify the format. -
     GET /assets/{uid}/submissions/{id}.xml
     GET /assets/{uid}/submissions/{id}.json
     
- or -
     GET /assets/{asset_uid}/submissions/{id}/?format=xml
     GET /assets/{asset_uid}/submissions/{id}/?format=json
     
- > Example > > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - Deletes current submission
     DELETE /assets/{uid}/submissions/{id}/
     
- - > Example > > curl -X DELETE https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/ - - Update current submission - _It's not possible to update a submission directly with `kpi`'s API. Instead, it returns the link where the instance can be opened for edition._ -
     GET /assets/{uid}/submissions/{id}/edit/
     
- > Example > > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/edit/ - - ### Validation statuses - Retrieves the validation status of a submission.
     GET /assets/{uid}/submissions/{id}/validation_status/
     
- > Example > > curl -X GET https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - Update the validation of a submission
     PATCH /assets/{uid}/submissions/{id}/validation_status/
     
- > Example > > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/234/validation_status/ - > **Payload** > > { > "validation_status.uid": > } - where `` is a string and can be one of theses values: - - `validation_status_approved` - `validation_status_not_approved` - `validation_status_on_hold` - Bulk update
     PATCH /assets/{uid}/submissions/validation_statuses/
     
- > Example > > curl -X PATCH https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/submissions/validation_statuses/ - > **Payload** > > { - > "submissions_ids": [{integer}], + > "submission_ids": [{integer}], > "validation_status.uid": > } - - ### CURRENT ENDPOINT """ -<<<<<<< HEAD def list(self, request, *args, **kwargs): format_type = kwargs.get('format', request.GET.get('format', 'json')) @@ -170,97 +110,3 @@ def list(self, request, *args, **kwargs): format_type=format_type, **filters) return Response(list(submissions)) -======= - parent_model = Asset - renderer_classes = (renderers.BrowsableAPIRenderer, - renderers.JSONRenderer, - SubmissionXMLRenderer - ) - permission_classes = (SubmissionPermission,) - - def _get_asset(self): - - if not hasattr(self, "_asset"): - asset_uid = self.get_parents_query_dict()['asset'] - asset = get_object_or_404(self.parent_model, uid=asset_uid) - self._asset = asset - - return self._asset - - def _get_deployment(self): - """ - Returns the deployment for the asset specified by the request - """ - asset = self._get_asset() - - if not asset.has_deployment: - raise serializers.ValidationError( - _('The specified asset has not been deployed')) - return asset.deployment - - def destroy(self, request, *args, **kwargs): - deployment = self._get_deployment() - pk = kwargs.get("pk") - json_response = deployment.delete_submission(pk, user=request.user) - return Response(**json_response) - - @detail_route(methods=['GET'], renderer_classes=[renderers.JSONRenderer]) - def edit(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.get_submission_edit_url(pk, user=request.user, params=request.GET) - return Response(**json_response) - - def list(self, request, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submissions = deployment.get_submissions(format_type=format_type, **filters) - return Response(list(submissions)) - - def retrieve(self, request, pk, *args, **kwargs): - format_type = kwargs.get("format", request.GET.get("format", "json")) - deployment = self._get_deployment() - filters = self._filter_mongo_query(request) - submission = deployment.get_submission(pk, format_type=format_type, **filters) - if not submission: - raise Http404 - return Response(submission) - - @detail_route(methods=["GET", "PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_status(self, request, pk, *args, **kwargs): - deployment = self._get_deployment() - if request.method == "PATCH": - json_response = deployment.set_validate_status(pk, request.data, request.user) - else: - json_response = deployment.get_validate_status(pk, request.GET, request.user) - - return Response(**json_response) - - @list_route(methods=["PATCH"], renderer_classes=[renderers.JSONRenderer]) - def validation_statuses(self, request, *args, **kwargs): - deployment = self._get_deployment() - json_response = deployment.set_validate_statuses(request.data, request.user) - - return Response(**json_response) - - def _filter_mongo_query(self, request): - """ - Build filters to pass to Mongo query. - Acts like Django `filter_backends` - - :param request: - :return: dict - """ - filters = {} - asset = self._get_asset() - - if request.method == "GET": - filters = request.GET.dict() - - submitted_by = asset.get_usernames_for_restricted_perm(request.user) - - filters.update({ - "submitted_by": submitted_by - }) - return filters ->>>>>>> Moved current views and serializers under 'v1' parent folder From 4356bde11cfe7744860c0129afda398f783430d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 8 May 2019 11:44:59 -0400 Subject: [PATCH 041/499] WIP - Hook app available for v1 and v2 --- kobo/apps/hook/serializers/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 kobo/apps/hook/serializers/__init__.py diff --git a/kobo/apps/hook/serializers/__init__.py b/kobo/apps/hook/serializers/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From 7b41ea1ca88a8575cd1b2253d9fdf5da88e234d9 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 8 May 2019 11:53:13 -0400 Subject: [PATCH 042/499] Make Hook app work with v1 & v2 --- kobo/apps/hook/serializers/__init__.py | 0 kobo/apps/hook/views/v2/hook_log.py | 42 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 kobo/apps/hook/serializers/__init__.py diff --git a/kobo/apps/hook/serializers/__init__.py b/kobo/apps/hook/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/kobo/apps/hook/views/v2/hook_log.py b/kobo/apps/hook/views/v2/hook_log.py index 106a432b51..72c22e2abf 100644 --- a/kobo/apps/hook/views/v2/hook_log.py +++ b/kobo/apps/hook/views/v2/hook_log.py @@ -1,13 +1,23 @@ +<<<<<<< HEAD # coding: utf-8 from django.utils.translation import ugettext as _ from rest_framework import viewsets, mixins, status from rest_framework.decorators import action +======= +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.utils.translation import ugettext as _ +from rest_framework import viewsets, mixins, status +from rest_framework.decorators import detail_route +>>>>>>> Make Hook app work with v1 & v2 from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin from kobo.apps.hook.constants import KOBO_INTERNAL_ERROR_STATUS_CODE from kobo.apps.hook.models.hook_log import HookLog from kobo.apps.hook.serializers.v2.hook_log import HookLogSerializer +<<<<<<< HEAD from kpi.paginators import TinyPaginated from kpi.permissions import AssetEditorSubmissionViewerPermission from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin @@ -15,6 +25,14 @@ class HookLogViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, +======= +from kpi.filters import AssetOwnerFilterBackend +from kpi.paginators import TinyPaginated +from kpi.permissions import AssetOwnerNestedObjectPermission + + +class HookLogViewSet(NestedViewSetMixin, +>>>>>>> Make Hook app work with v1 & v2 mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): @@ -25,12 +43,20 @@ class HookLogViewSet(AssetNestedObjectViewsetMixin, #### Lists logs of an external services endpoints accessible to requesting user
+<<<<<<< HEAD
     GET /api/v2/assets/{asset_uid}/hooks/{hook_uid}/logs/
+=======
+    GET /assets/{asset_uid}/hooks/{hook_uid}/logs/
+>>>>>>> Make Hook app work with v1 & v2
     
> Example > +<<<<<<< HEAD > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hSBxsiVNa5UxkVAjwu6dFB/logs/ +======= + > curl -X GET https://[kpi-url]/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hSBxsiVNa5UxkVAjwu6dFB/logs/ +>>>>>>> Make Hook app work with v1 & v2 @@ -40,23 +66,39 @@ class HookLogViewSet(AssetNestedObjectViewsetMixin, #### Retrieves a log
+<<<<<<< HEAD
     GET /api/v2/assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/
+=======
+    GET /assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/
+>>>>>>> Make Hook app work with v1 & v2
     
> Example > +<<<<<<< HEAD > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/ +======= + > curl -X GET https://[kpi-url]/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/ +>>>>>>> Make Hook app work with v1 & v2 #### Retries a failed attempt
+<<<<<<< HEAD
     PATCH /api/v2/assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/retry/
+=======
+    PATCH /assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/retry/
+>>>>>>> Make Hook app work with v1 & v2
     
> Example > +<<<<<<< HEAD > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/retry/ +======= + > curl -X GET https://[kpi-url]/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/retry/ +>>>>>>> Make Hook app work with v1 & v2 ### CURRENT ENDPOINT From c7b53390f876430efcd8cc5f81161ebf9f4b0e92 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 8 May 2019 15:04:54 -0400 Subject: [PATCH 043/499] AssetFile added to v2 --- kpi/serializers/v2/asset_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kpi/serializers/v2/asset_file.py b/kpi/serializers/v2/asset_file.py index 719f4576f3..008f47dce1 100644 --- a/kpi/serializers/v2/asset_file.py +++ b/kpi/serializers/v2/asset_file.py @@ -1,10 +1,10 @@ # coding: utf-8 from rest_framework import serializers -from rest_framework.reverse import reverse from kpi.fields import RelativePrefixHyperlinkedRelatedField, \ SerializerMethodFileField, WritableJSONField from kpi.models import AssetFile +from kpi.utils.url_helper import UrlHelper class AssetFileSerializer(serializers.ModelSerializer): From 29fe1342e03a727f5caeb10f0548c24d7fe261c7 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 8 May 2019 19:33:51 -0400 Subject: [PATCH 044/499] WIP - Nested asset's permissions --- kpi/serializers/v2/object_permission.py | 84 ------------------------- kpi/views/v2/asset_file.py | 2 +- 2 files changed, 1 insertion(+), 85 deletions(-) delete mode 100644 kpi/serializers/v2/object_permission.py diff --git a/kpi/serializers/v2/object_permission.py b/kpi/serializers/v2/object_permission.py deleted file mode 100644 index 7f9dbb74ee..0000000000 --- a/kpi/serializers/v2/object_permission.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType -from django.db import transaction -from rest_framework import serializers - -from kpi.constants import PERM_FROM_KC_ONLY -from kpi.fields import GenericHyperlinkedRelatedField, \ - RelativePrefixHyperlinkedRelatedField -from kpi.models import ObjectPermission - - -class ObjectPermissionSerializer(serializers.ModelSerializer): - url = serializers.HyperlinkedIdentityField( - lookup_field='uid', - view_name='objectpermission-detail' - ) - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - ) - permission = serializers.SlugRelatedField( - slug_field='codename', - queryset=Permission.objects.all() - ) - content_object = GenericHyperlinkedRelatedField( - lookup_field='uid', - style={'base_template': 'input.html'} # Render as a simple text box - ) - inherited = serializers.ReadOnlyField() - - class Meta: - model = ObjectPermission - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'content_object', - 'deny', - 'inherited', - ) - extra_kwargs = { - 'uid': { - 'read_only': True, - }, - } - - def create(self, validated_data): - content_object = validated_data['content_object'] - user = validated_data['user'] - perm = validated_data['permission'].codename - with transaction.atomic(): - # TEMPORARY Issue #1161: something other than KC is setting a - # permission; clear the `from_kc_only` flag - ObjectPermission.objects.filter( - user=user, - permission__codename=PERM_FROM_KC_ONLY, - object_id=content_object.id, - content_type=ContentType.objects.get_for_model(content_object) - ).delete() - return content_object.assign_perm(user, perm) - - -class ObjectPermissionNestedSerializer(ObjectPermissionSerializer): - ''' - When serializing a list of permissions inside the object to which they are - assigned, omit `content_object` to improve performance significantly - ''' - class Meta(ObjectPermissionSerializer.Meta): - fields = ( - 'uid', - 'kind', - 'url', - 'user', - 'permission', - 'deny', - 'inherited', - ) diff --git a/kpi/views/v2/asset_file.py b/kpi/views/v2/asset_file.py index c4f9122ddb..abd234851d 100644 --- a/kpi/views/v2/asset_file.py +++ b/kpi/views/v2/asset_file.py @@ -1,4 +1,4 @@ -# coding: utf-8 + from private_storage.views import PrivateStorageDetailView from rest_framework import exceptions from rest_framework.decorators import action From aa027cab56aa7dad80f604da06d9c28df18dac78 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 9 May 2019 09:50:26 -0400 Subject: [PATCH 045/499] WIP - Nested permissions --- kpi/serializers/v2/asset_permission.py | 39 +++++++++++++ kpi/views/v2/asset_permission.py | 80 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 kpi/serializers/v2/asset_permission.py create mode 100644 kpi/views/v2/asset_permission.py diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py new file mode 100644 index 0000000000..ba3529d1b2 --- /dev/null +++ b/kpi/serializers/v2/asset_permission.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.contrib.auth.models import User +from rest_framework import serializers + +from kpi.fields.relative_prefix_hyperlinked_related import \ + RelativePrefixHyperlinkedRelatedField +from kpi.models.asset import Asset +from kpi.models.object_permission import ObjectPermission +from kpi.utils.url_helper import UrlHelper + + +class AssetPermissionSerializer(serializers.ModelSerializer): + + url = serializers.SerializerMethodField() + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + style={'base_template': 'input.html'} # Render as a simple text box + ) + + class Meta: + model = ObjectPermission + fields = ( + 'url', + 'user', + 'permission', + ) + + read_only_fields = ('uid', ) + + def get_url(self, assignment): + asset_uid = self.context.get('asset_uid') + return UrlHelper.reverse('asset-permission-detail', + args=(asset_uid, assignment.uid), + request=self.context.get('request', None), + context=self.context) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py new file mode 100644 index 0000000000..60240c61d3 --- /dev/null +++ b/kpi/views/v2/asset_permission.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from datetime import timedelta + +import constance +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.translation import ugettext as _ +from rest_framework import viewsets, status +from rest_framework.decorators import detail_route +from rest_framework.response import Response +from rest_framework_extensions.mixins import NestedViewSetMixin + + +from kobo.apps.hook.models import Hook, HookLog + +from kpi.filters import AssetOwnerFilterBackend +from kpi.models import Asset, ObjectPermission +from kpi.permissions import AssetEditorPermission +from kpi.serializers.v2.asset_permission import AssetPermissionSerializer + + +class AssetPermissionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): + """ + + ### CURRENT ENDPOINT + """ + + URL_NAMESPACE = 'api_v2' + + model = ObjectPermission + lookup_field = "uid" + #filter_backends = ( + # AssetOwnerFilterBackend, + #) + serializer_class = AssetPermissionSerializer + permission_classes = (AssetEditorPermission,) + + @property + def asset(self): + if not hasattr(self, '_asset'): + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(Asset, uid=asset_uid) + setattr(self, '_asset', asset) + return self._asset + + def get_serializer_context(self): + """ + Extra context provided to the serializer class. + Inject asset_uid to avoid extra queries to DB inside the serializer. + @TODO Check if there is a better way to do it? + """ + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self, + 'asset_uid': self.asset.uid + } + + def get_queryset(self): + print("QUERYSET") + queryset = self.model.objects.filter(object_id=self.asset.pk) + return queryset + + def list(self, request, *args, **kwargs): + print("LIST") + return super(AssetPermissionViewSet, self).list(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(asset=self.asset) + + #def perform_create(self, serializer): + # # Make sure the requesting user has the share_ permission on + # # the affected object + # codename = serializer.validated_data['permission'].codename + # if not self._requesting_user_can_share(affected_object, codename): + # raise exceptions.PermissionDenied() + # serializer.save() From 48b92dd461c91a5d2915209234831f89a7b80e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 9 May 2019 16:58:07 -0400 Subject: [PATCH 046/499] Added new endpoint for permissions to API v2 --- kpi/serializers/v2/asset.py | 11 +++++++++++ kpi/serializers/v2/asset_permission.py | 14 +++++++++++--- kpi/urls/router_api_v2.py | 1 - kpi/views/v2/asset_permission.py | 14 -------------- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index ab87e938cb..471199f3b6 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -319,6 +319,17 @@ def get_permissions(self, obj): many=True, read_only=True, context=context).data + def get_permissions(self, obj): + object_permissions_queryset = ObjectPermission.objects.filter( + object_id=obj.id).all() + + context = self.context + context.update({'asset_uid': obj.uid}) + + return AssetPermissionSerializer(object_permissions_queryset, + many=True, read_only=True, + context=context).data + def _content(self, obj): return json.dumps(obj.content) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index ba3529d1b2..08c28e27f2 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -6,7 +6,6 @@ from kpi.fields.relative_prefix_hyperlinked_related import \ RelativePrefixHyperlinkedRelatedField -from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission from kpi.utils.url_helper import UrlHelper @@ -20,6 +19,7 @@ class AssetPermissionSerializer(serializers.ModelSerializer): queryset=User.objects.all(), style={'base_template': 'input.html'} # Render as a simple text box ) + permission = serializers.SerializerMethodField() class Meta: model = ObjectPermission @@ -31,9 +31,17 @@ class Meta: read_only_fields = ('uid', ) - def get_url(self, assignment): + def get_permission(self, object_permission): + codename = object_permission.permission.codename + return UrlHelper.reverse('permission-detail', + args=(codename,), + request=self.context.get('request', None), + context=self.context) + + def get_url(self, object_permission): asset_uid = self.context.get('asset_uid') return UrlHelper.reverse('asset-permission-detail', - args=(asset_uid, assignment.uid), + args=(asset_uid, object_permission.uid), request=self.context.get('request', None), context=self.context) + diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index aff1dbe492..f8d11f96b8 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -13,7 +13,6 @@ from kpi.views.v2.collection import CollectionViewSet from kpi.views.v2.collection_permission_assignment import CollectionPermissionAssignmentViewSet from kpi.views.v2.data import DataViewSet - from kpi.views.v2.permission import PermissionViewSet from kpi.views.v2.user import UserViewSet diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 60240c61d3..8bb5170d4e 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -1,22 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from datetime import timedelta - -import constance -from django.db.models import Q from django.shortcuts import get_object_or_404 -from django.utils import timezone -from django.utils.translation import ugettext as _ from rest_framework import viewsets, status -from rest_framework.decorators import detail_route -from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin - -from kobo.apps.hook.models import Hook, HookLog - -from kpi.filters import AssetOwnerFilterBackend from kpi.models import Asset, ObjectPermission from kpi.permissions import AssetEditorPermission from kpi.serializers.v2.asset_permission import AssetPermissionSerializer @@ -60,12 +48,10 @@ def get_serializer_context(self): } def get_queryset(self): - print("QUERYSET") queryset = self.model.objects.filter(object_id=self.asset.pk) return queryset def list(self, request, *args, **kwargs): - print("LIST") return super(AssetPermissionViewSet, self).list(request, *args, **kwargs) def perform_create(self, serializer): From 8b03b3a2fe0899c14dd02cda2d2e728dadee7c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 10 May 2019 11:36:54 -0400 Subject: [PATCH 047/499] Refactored permissions classes to support Asset nested objects --- kobo/apps/hook/views/v2/hook_log.py | 43 +---------------------------- kpi/permissions.py | 25 +++++++++++++++++ kpi/utils/viewset_mixin.py | 17 ++++++++++++ kpi/views/v2/asset_permission.py | 19 ++++--------- 4 files changed, 49 insertions(+), 55 deletions(-) create mode 100644 kpi/utils/viewset_mixin.py diff --git a/kobo/apps/hook/views/v2/hook_log.py b/kobo/apps/hook/views/v2/hook_log.py index 72c22e2abf..319db14eb3 100644 --- a/kobo/apps/hook/views/v2/hook_log.py +++ b/kobo/apps/hook/views/v2/hook_log.py @@ -1,23 +1,14 @@ -<<<<<<< HEAD # coding: utf-8 from django.utils.translation import ugettext as _ from rest_framework import viewsets, mixins, status from rest_framework.decorators import action -======= -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.utils.translation import ugettext as _ -from rest_framework import viewsets, mixins, status -from rest_framework.decorators import detail_route ->>>>>>> Make Hook app work with v1 & v2 from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin from kobo.apps.hook.constants import KOBO_INTERNAL_ERROR_STATUS_CODE from kobo.apps.hook.models.hook_log import HookLog from kobo.apps.hook.serializers.v2.hook_log import HookLogSerializer -<<<<<<< HEAD + from kpi.paginators import TinyPaginated from kpi.permissions import AssetEditorSubmissionViewerPermission from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin @@ -25,14 +16,6 @@ class HookLogViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, -======= -from kpi.filters import AssetOwnerFilterBackend -from kpi.paginators import TinyPaginated -from kpi.permissions import AssetOwnerNestedObjectPermission - - -class HookLogViewSet(NestedViewSetMixin, ->>>>>>> Make Hook app work with v1 & v2 mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): @@ -43,20 +26,12 @@ class HookLogViewSet(NestedViewSetMixin, #### Lists logs of an external services endpoints accessible to requesting user
-<<<<<<< HEAD
     GET /api/v2/assets/{asset_uid}/hooks/{hook_uid}/logs/
-=======
-    GET /assets/{asset_uid}/hooks/{hook_uid}/logs/
->>>>>>> Make Hook app work with v1 & v2
     
> Example > -<<<<<<< HEAD > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hSBxsiVNa5UxkVAjwu6dFB/logs/ -======= - > curl -X GET https://[kpi-url]/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hSBxsiVNa5UxkVAjwu6dFB/logs/ ->>>>>>> Make Hook app work with v1 & v2 @@ -66,39 +41,23 @@ class HookLogViewSet(NestedViewSetMixin, #### Retrieves a log
-<<<<<<< HEAD
     GET /api/v2/assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/
-=======
-    GET /assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/
->>>>>>> Make Hook app work with v1 & v2
     
> Example > -<<<<<<< HEAD > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/ -======= - > curl -X GET https://[kpi-url]/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/ ->>>>>>> Make Hook app work with v1 & v2 #### Retries a failed attempt
-<<<<<<< HEAD
     PATCH /api/v2/assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/retry/
-=======
-    PATCH /assets/{asset_uid}/hooks/{hook_uid}/logs/{uid}/retry/
->>>>>>> Make Hook app work with v1 & v2
     
> Example > -<<<<<<< HEAD > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/retry/ -======= - > curl -X GET https://[kpi-url]/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/logs/3005940a-6e30-4699-813a-0ee5b2b07395/retry/ ->>>>>>> Make Hook app work with v1 & v2 ### CURRENT ENDPOINT diff --git a/kpi/permissions.py b/kpi/permissions.py index c5a08b939b..0bed252430 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -209,6 +209,31 @@ class PostMappedToChangePermission(IsOwnerOrReadOnly): perms_map['POST'] = ['%(app_label)s.change_%(model_name)s'] +# FIXME: Name is no longer accurate. +class IsOwnerOrReadOnly(permissions.DjangoObjectPermissions): + """ + Custom permission to only allow owners of an object to edit it. + """ + + # Setting this to False allows real permission checking on AnonymousUser. + # With the default of True, anonymous requests are categorically rejected. + authenticated_users_only = False + + perms_map = permissions.DjangoObjectPermissions.perms_map + perms_map['GET'] = ['%(app_label)s.view_%(model_name)s'] + perms_map['OPTIONS'] = perms_map['GET'] + perms_map['HEAD'] = perms_map['GET'] + + +class PostMappedToChangePermission(IsOwnerOrReadOnly): + """ + Maps POST requests to the change_model permission instead of DRF's default + of add_model + """ + perms_map = IsOwnerOrReadOnly.perms_map + perms_map['POST'] = ['%(app_label)s.change_%(model_name)s'] + + class SubmissionPermission(AssetNestedObjectPermission): """ Permissions for submissions. diff --git a/kpi/utils/viewset_mixin.py b/kpi/utils/viewset_mixin.py new file mode 100644 index 0000000000..6e83f8291a --- /dev/null +++ b/kpi/utils/viewset_mixin.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from django.shortcuts import get_object_or_404 + +from kpi.models import Asset + + +class AssetNestedObjectViewsetMixin: + + @property + def asset(self): + if not hasattr(self, '_asset'): + asset_uid = self.get_parents_query_dict().get("asset") + asset = get_object_or_404(Asset, uid=asset_uid) + setattr(self, '_asset', asset) + return self._asset diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 8bb5170d4e..4cb8d489cf 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -1,16 +1,17 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from django.shortcuts import get_object_or_404 from rest_framework import viewsets, status from rest_framework_extensions.mixins import NestedViewSetMixin -from kpi.models import Asset, ObjectPermission -from kpi.permissions import AssetEditorPermission +from kpi.models import ObjectPermission +from kpi.permissions import AssetNestedObjectPermission from kpi.serializers.v2.asset_permission import AssetPermissionSerializer +from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin -class AssetPermissionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): +class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, + viewsets.ModelViewSet): """ ### CURRENT ENDPOINT @@ -24,15 +25,7 @@ class AssetPermissionViewSet(NestedViewSetMixin, viewsets.ModelViewSet): # AssetOwnerFilterBackend, #) serializer_class = AssetPermissionSerializer - permission_classes = (AssetEditorPermission,) - - @property - def asset(self): - if not hasattr(self, '_asset'): - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(Asset, uid=asset_uid) - setattr(self, '_asset', asset) - return self._asset + permission_classes = (AssetNestedObjectPermission,) def get_serializer_context(self): """ From ed387f95e0b162d1bb9b226f140b332840f6bdf1 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Fri, 10 May 2019 15:06:03 -0400 Subject: [PATCH 048/499] Filter permissions list of asset based on users' permissions --- kpi/serializers/v2/asset.py | 8 ++++---- kpi/views/v2/asset_permission.py | 10 ++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index 471199f3b6..157990e206 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -320,13 +320,13 @@ def get_permissions(self, obj): context=context).data def get_permissions(self, obj): - object_permissions_queryset = ObjectPermission.objects.filter( - object_id=obj.id).all() - context = self.context + request = self.context.get('request') + queryset = ObjectPermissionHelper.get_assignments_queryset(obj, + request.user) context.update({'asset_uid': obj.uid}) - return AssetPermissionSerializer(object_permissions_queryset, + return AssetPermissionSerializer(queryset.all(), many=True, read_only=True, context=context).data diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 4cb8d489cf..74213a2f16 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -4,10 +4,11 @@ from rest_framework import viewsets, status from rest_framework_extensions.mixins import NestedViewSetMixin -from kpi.models import ObjectPermission +from kpi.models.object_permission import ObjectPermission from kpi.permissions import AssetNestedObjectPermission from kpi.serializers.v2.asset_permission import AssetPermissionSerializer from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin +from kpi.utils.object_permission_helper import ObjectPermissionHelper class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, @@ -21,9 +22,6 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, model = ObjectPermission lookup_field = "uid" - #filter_backends = ( - # AssetOwnerFilterBackend, - #) serializer_class = AssetPermissionSerializer permission_classes = (AssetNestedObjectPermission,) @@ -41,8 +39,8 @@ def get_serializer_context(self): } def get_queryset(self): - queryset = self.model.objects.filter(object_id=self.asset.pk) - return queryset + return ObjectPermissionHelper.get_assignments_queryset(self.asset, + self.request.user) def list(self, request, *args, **kwargs): return super(AssetPermissionViewSet, self).list(request, *args, **kwargs) From 41520af0e773ef4668e35467d0dedb6fe3866664 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Mon, 13 May 2019 11:11:30 -0400 Subject: [PATCH 049/499] Working nested permissions serializer, refactored Asset nested View (to use AssetNestedMixin) --- kobo/apps/hook/views/v2/hook.py | 6 ++++ kpi/models/asset.py | 4 +-- kpi/serializers/v2/asset_permission.py | 49 +++++++++++++++++++++++--- kpi/utils/viewset_mixin.py | 10 ++++-- kpi/views/v2/asset.py | 3 ++ kpi/views/v2/asset_file.py | 1 - kpi/views/v2/hook_signal.py | 13 ++++--- 7 files changed, 70 insertions(+), 16 deletions(-) diff --git a/kobo/apps/hook/views/v2/hook.py b/kobo/apps/hook/views/v2/hook.py index e9d5e706d4..b63c47770c 100644 --- a/kobo/apps/hook/views/v2/hook.py +++ b/kobo/apps/hook/views/v2/hook.py @@ -14,8 +14,14 @@ from kobo.apps.hook.models import Hook, HookLog from kobo.apps.hook.serializers.v2.hook import HookSerializer from kobo.apps.hook.tasks import retry_all_task +<<<<<<< HEAD from kpi.permissions import AssetEditorSubmissionViewerPermission from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin +======= +from kpi.filters import AssetOwnerFilterBackend +from kpi.permissions import AssetOwnerNestedObjectPermission +from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin +>>>>>>> Working nested permissions serializer, refactored Asset nested View (to use AssetNestedMixin) class HookViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, diff --git a/kpi/models/asset.py b/kpi/models/asset.py index b90cc6dc40..d738ab4e41 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -846,7 +846,7 @@ def get_partial_perms( else: return list(perms) - def get_usernames_for_restricted_perm(self, user_obj, perm=PERM_VIEW_SUBMISSIONS): + def get_usernames_for_restricted_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): """ Returns the list of usernames for a specfic permission `perm` and this specific asset. @@ -859,7 +859,7 @@ def get_usernames_for_restricted_perm(self, user_obj, perm=PERM_VIEW_SUBMISSIONS raise BadPermissionsException("Only global permissions for " "submissions are supported.") - perms = self.get_restricted_perms(user_obj, True) + perms = self.get_restricted_perms(user_id, True) if perms: return perms.get(perm) return None diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 08c28e27f2..9553f50a59 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -4,6 +4,7 @@ from django.contrib.auth.models import User from rest_framework import serializers +from kpi.constants import PREFIX_RESTRICTED_PERMS from kpi.fields.relative_prefix_hyperlinked_related import \ RelativePrefixHyperlinkedRelatedField from kpi.models.object_permission import ObjectPermission @@ -20,6 +21,7 @@ class AssetPermissionSerializer(serializers.ModelSerializer): style={'base_template': 'input.html'} # Render as a simple text box ) permission = serializers.SerializerMethodField() + partial_permissions = serializers.SerializerMethodField() class Meta: model = ObjectPermission @@ -27,16 +29,32 @@ class Meta: 'url', 'user', 'permission', + 'partial_permissions' ) read_only_fields = ('uid', ) + def get_partial_permissions(self, object_permission): + codename = object_permission.permission.codename + if codename.startswith(PREFIX_RESTRICTED_PERMS): + view = self.context.get('view') + asset = view.asset + restricted_perms = asset.get_restricted_perms( + object_permission.user_id, True) + + hyperlinked_restricted_perms = {} + for perm_codename, users in restricted_perms.items(): + url = self.__get_permission_hyperlink(perm_codename) + hyperlinked_restricted_perms[perm_codename] = { + 'url': url, + 'users': [self.__get_user_hyperlink(user) for user in users] + } + return hyperlinked_restricted_perms + return None + def get_permission(self, object_permission): codename = object_permission.permission.codename - return UrlHelper.reverse('permission-detail', - args=(codename,), - request=self.context.get('request', None), - context=self.context) + return self.__get_permission_hyperlink(codename) def get_url(self, object_permission): asset_uid = self.context.get('asset_uid') @@ -45,3 +63,26 @@ def get_url(self, object_permission): request=self.context.get('request', None), context=self.context) + def to_representation(self, instance): + """ + Doesn't display 'partial_permissions' attribute if it's `None`. + """ + repr_ = super(AssetPermissionSerializer, self).to_representation(instance) + for k, v in repr_.items(): + if k == 'partial_permissions' and v is None: + del repr_[k] + + return repr_ + + def __get_permission_hyperlink(self, codename): + return UrlHelper.reverse('permission-detail', + args=(codename,), + request=self.context.get('request', None), + context=self.context) + + def __get_user_hyperlink(self, username): + return UrlHelper.reverse('user-detail', + args=(username,), + request=self.context.get('request', None), + context=self.context) + diff --git a/kpi/utils/viewset_mixin.py b/kpi/utils/viewset_mixin.py index 6e83f8291a..4f02fc9327 100644 --- a/kpi/utils/viewset_mixin.py +++ b/kpi/utils/viewset_mixin.py @@ -11,7 +11,13 @@ class AssetNestedObjectViewsetMixin: @property def asset(self): if not hasattr(self, '_asset'): - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(Asset, uid=asset_uid) + asset = get_object_or_404(Asset, uid=self.asset_uid) setattr(self, '_asset', asset) return self._asset + + @property + def asset_uid(self): + if not hasattr(self, '_asset_uid'): + asset_uid = self.get_parents_query_dict().get("asset") + setattr(self, '_asset_uid', asset_uid) + return self._asset_uid diff --git a/kpi/views/v2/asset.py b/kpi/views/v2/asset.py index 59ba4327a5..c1278b6069 100644 --- a/kpi/views/v2/asset.py +++ b/kpi/views/v2/asset.py @@ -194,6 +194,9 @@ class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): ) def get_serializer_class(self): + # need to initialise `self.asset` for the serializer. + # Serializer needs `self.context.get('view').asset + self.asset = self.get_object() if self.action == 'list': return AssetListSerializer else: diff --git a/kpi/views/v2/asset_file.py b/kpi/views/v2/asset_file.py index abd234851d..f7dc349c6c 100644 --- a/kpi/views/v2/asset_file.py +++ b/kpi/views/v2/asset_file.py @@ -1,4 +1,3 @@ - from private_storage.views import PrivateStorageDetailView from rest_framework import exceptions from rest_framework.decorators import action diff --git a/kpi/views/v2/hook_signal.py b/kpi/views/v2/hook_signal.py index bb112799b2..441b8b8267 100644 --- a/kpi/views/v2/hook_signal.py +++ b/kpi/views/v2/hook_signal.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext_lazy as _ from rest_framework import status, viewsets from rest_framework.response import Response @@ -10,9 +9,11 @@ from kobo.apps.hook.utils import HookUtils from kpi.models import Asset from kpi.utils.log import logging +from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin -class HookSignalViewSet(NestedViewSetMixin, viewsets.ViewSet): +class HookSignalViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, + viewsets.ViewSet): """ ## This endpoint is only used to trigger asset's hooks if any. @@ -54,19 +55,17 @@ def create(self, request, *args, **kwargs): "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") } try: - asset_uid = self.get_parents_query_dict().get("asset") - asset = get_object_or_404(self.parent_model, uid=asset_uid) instance_id = request.data.get("instance_id") - instance = asset.deployment.get_submission(instance_id) + instance = self.asset.deployment.get_submission(instance_id) # Check if instance really belongs to Asset. - if not (instance and instance.get(asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): + if not (instance and instance.get(self.asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): response_status_code = status.HTTP_404_NOT_FOUND response = { "detail": _("Resource not found") } - elif not HookUtils.call_services(asset, instance_id): + elif not HookUtils.call_services(self.asset, instance_id): response_status_code = status.HTTP_409_CONFLICT response = { "detail": _( From cbd65e26206da45516d01477cb115996cdffb973 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Mon, 13 May 2019 11:59:09 -0400 Subject: [PATCH 050/499] Refactoring - Changed 'restricted_' to 'partial_' prefix for permissions --- kpi/migrations/0023_partial_permissions.py | 2 +- kpi/migrations/0023_restricted_permissions.py | 42 ------------------- kpi/models/asset.py | 6 +-- kpi/models/asset_user_partial_permission.py | 2 - .../asset_user_restricted_permission.py | 41 ------------------ kpi/serializers/v2/asset_permission.py | 14 +++---- kpi/tests/api/v2/test_api_assets.py | 2 +- kpi/tests/test_api_submissions.py | 18 ++++---- 8 files changed, 21 insertions(+), 106 deletions(-) delete mode 100644 kpi/migrations/0023_restricted_permissions.py delete mode 100644 kpi/models/asset_user_restricted_permission.py diff --git a/kpi/migrations/0023_partial_permissions.py b/kpi/migrations/0023_partial_permissions.py index 8b05ad6fe6..48b8842b91 100644 --- a/kpi/migrations/0023_partial_permissions.py +++ b/kpi/migrations/0023_partial_permissions.py @@ -41,4 +41,4 @@ class Migration(migrations.Migration): name='assetuserpartialpermission', unique_together=set([('asset', 'user')]), ), - ] + ] \ No newline at end of file diff --git a/kpi/migrations/0023_restricted_permissions.py b/kpi/migrations/0023_restricted_permissions.py deleted file mode 100644 index 7c3184101a..0000000000 --- a/kpi/migrations/0023_restricted_permissions.py +++ /dev/null @@ -1,42 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import jsonfield.fields -import kpi.models.asset_file -import private_storage.storage.s3boto3 -from django.conf import settings -import django.utils.timezone -import private_storage.fields -import kpi.models.import_export_task -import jsonbfield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('kpi', '0022_assetfile'), - ] - - operations = [ - migrations.CreateModel( - name='AssetUserRestrictedPermission', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('permissions', jsonbfield.fields.JSONField(default=dict)), - ('date_created', models.DateTimeField(default=django.utils.timezone.now)), - ('date_modified', models.DateTimeField(default=django.utils.timezone.now)), - ], - ), - migrations.AddField( - model_name='assetuserrestrictedpermission', - name='asset', - field=models.ForeignKey(related_name='asset_supervisor_permissions', to='kpi.Asset'), - ), - migrations.AddField( - model_name='assetuserrestrictedpermission', - name='user', - field=models.ForeignKey(related_name='user_supervisor_permissions', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/kpi/models/asset.py b/kpi/models/asset.py index d738ab4e41..cf380d3183 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -846,7 +846,7 @@ def get_partial_perms( else: return list(perms) - def get_usernames_for_restricted_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): + def get_usernames_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): """ Returns the list of usernames for a specfic permission `perm` and this specific asset. @@ -855,11 +855,11 @@ def get_usernames_for_restricted_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS) :return: """ if not (perm.endswith(SUFFIX_SUBMISSIONS_PERMS) and - not perm == PERM_RESTRICTED_SUBMISSIONS): + not perm == PERM_PARTIAL_SUBMISSIONS): raise BadPermissionsException("Only global permissions for " "submissions are supported.") - perms = self.get_restricted_perms(user_id, True) + perms = self.get_partial_perms(user_id, True) if perms: return perms.get(perm) return None diff --git a/kpi/models/asset_user_partial_permission.py b/kpi/models/asset_user_partial_permission.py index b165d6e21c..07b2d1f6e6 100644 --- a/kpi/models/asset_user_partial_permission.py +++ b/kpi/models/asset_user_partial_permission.py @@ -8,7 +8,6 @@ class AssetUserPartialPermission(models.Model): """ Many-to-Many table which provides users' permissions on other users' submissions - For example, - Asset: - uid: aAAAAAA @@ -21,7 +20,6 @@ class AssetUserPartialPermission(models.Model): `permissions` is dict formatted as is: asset_id | user_id | permissions 1 | 1 | {"someuser": ["view_submissions"]} - Using a list per user for permissions, gives the opportunity to add other permissions such as `change_submissions`, `delete_submissions` for later purpose """ diff --git a/kpi/models/asset_user_restricted_permission.py b/kpi/models/asset_user_restricted_permission.py deleted file mode 100644 index fb92a01981..0000000000 --- a/kpi/models/asset_user_restricted_permission.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.utils import timezone -from django.db import models -from jsonbfield.fields import JSONField as JSONBField - - -class AssetUserRestrictedPermission(models.Model): - """ - Many-to-Many table which provides users' permissions - on other users' submissions - - For example, - - Asset: - - uid: aAAAAAA - - id: 1 - - User: - - username: someuser - - id: 1 - We want someuser to be able to view otheruser's submissions - Records should be - `permissions` is dict formatted as is: - asset_id | user_id | permissions - 1 | 1 | {"someuser": ["view_submissions"]} - - Using a list per user for permissions, gives the opportunity to add other permissions - such as `change_submissions`, `delete_submissions` for later purpose - """ - asset = models.ForeignKey('Asset', related_name='asset_supervisor_permissions', on_delete=models.CASCADE) - user = models.ForeignKey('auth.User', related_name='user_supervisor_permissions', on_delete=models.CASCADE) - permissions = JSONBField(default=dict) - date_created = models.DateTimeField(default=timezone.now) - date_modified = models.DateTimeField(default=timezone.now) - - def save(self, *args, **kwargs): - - if self.pk is not None: - self.date_modified = timezone.now() - - super(AssetUserSupervisorPermissions, self).save(*args, **kwargs) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 9553f50a59..a0feeb6712 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from rest_framework import serializers -from kpi.constants import PREFIX_RESTRICTED_PERMS +from kpi.constants import PREFIX_PARTIAL_PERMS from kpi.fields.relative_prefix_hyperlinked_related import \ RelativePrefixHyperlinkedRelatedField from kpi.models.object_permission import ObjectPermission @@ -36,20 +36,20 @@ class Meta: def get_partial_permissions(self, object_permission): codename = object_permission.permission.codename - if codename.startswith(PREFIX_RESTRICTED_PERMS): + if codename.startswith(PREFIX_PARTIAL_PERMS): view = self.context.get('view') asset = view.asset - restricted_perms = asset.get_restricted_perms( + partial_perms = asset.get_partial_perms( object_permission.user_id, True) - hyperlinked_restricted_perms = {} - for perm_codename, users in restricted_perms.items(): + hyperlinked_partial_perms = {} + for perm_codename, users in partial_perms.items(): url = self.__get_permission_hyperlink(perm_codename) - hyperlinked_restricted_perms[perm_codename] = { + hyperlinked_partial_perms[perm_codename] = { 'url': url, 'users': [self.__get_user_hyperlink(user) for user in users] } - return hyperlinked_restricted_perms + return hyperlinked_partial_perms return None def get_permission(self, object_permission): diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index f36860eafe..7a7dd1a231 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -208,7 +208,7 @@ def test_asset_version_content_hash(self): self.assertEqual(resp2.data['content_hash'], asset.latest_version.content_hash) - def test_restricted_access_to_version(self): + def test_partial_access_to_version(self): self.client.logout() self.client.login(username='anotheruser', password='anotheruser') resp = self.client.get(self.version_list_url, format='json') diff --git a/kpi/tests/test_api_submissions.py b/kpi/tests/test_api_submissions.py index 145b92ccf1..d6c3dad755 100644 --- a/kpi/tests/test_api_submissions.py +++ b/kpi/tests/test_api_submissions.py @@ -14,7 +14,7 @@ from kpi.models import Asset from kpi.constants import INSTANCE_FORMAT_TYPE_JSON, PERM_VIEW_SUBMISSIONS,\ - PERM_RESTRICTED_SUBMISSIONS + PERM_PARTIAL_SUBMISSIONS from .kpi_test_case import KpiTestCase @@ -118,16 +118,16 @@ def test_list_submissions_shared_other(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, self.submissions) - def test_list_submissions_with_restricted_permissions(self): + def test_list_submissions_with_partial_permissions(self): self._other_user_login() - restricted_perms = { + partial_perms = { PERM_VIEW_SUBMISSIONS: [self.someuser.username] } response = self.client.get(self.submission_url, {"format": "json"}) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.asset.assign_perm(self.anotheruser, PERM_RESTRICTED_SUBMISSIONS, - restricted_perms=restricted_perms) + self.asset.assign_perm(self.anotheruser, PERM_PARTIAL_SUBMISSIONS, + partial_perms=partial_perms) response = self.client.get(self.submission_url, {"format": "json"}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(self.asset.deployment.submission_count == 2) @@ -163,13 +163,13 @@ def test_retrieve_submission_shared_other(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, submission) - def test_retrieve_submission_with_restricted_permissions(self): + def test_retrieve_submission_with_partial_permissions(self): self._other_user_login() - restricted_perms = { + partial_perms = { PERM_VIEW_SUBMISSIONS: [self.someuser.username] } - self.asset.assign_perm(self.anotheruser, PERM_RESTRICTED_SUBMISSIONS, - restricted_perms=restricted_perms) + self.asset.assign_perm(self.anotheruser, PERM_PARTIAL_SUBMISSIONS, + partial_perms=partial_perms) # Try first submission submitted by unknown submission = self.submissions[0] From fd6e1bcea894d58ac65a0d8d45705c35a743b2fb Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Mon, 13 May 2019 18:11:16 -0400 Subject: [PATCH 051/499] Fixed permissions serializer within Asset --- kpi/serializers/v2/asset.py | 4 ++++ kpi/serializers/v2/asset_permission.py | 4 +++- kpi/views/v2/asset.py | 3 --- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index 157990e206..d788ea69b6 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -324,6 +324,10 @@ def get_permissions(self, obj): request = self.context.get('request') queryset = ObjectPermissionHelper.get_assignments_queryset(obj, request.user) + # Need to pass `asset` and `asset_uid` to context of + # AssetPermissionSerializer serializer to avoid extra queries to DB + # within the serializer to retrieve the asset object. + context.update({'asset': obj}) context.update({'asset_uid': obj.uid}) return AssetPermissionSerializer(queryset.all(), diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index a0feeb6712..7901faa3e9 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -38,7 +38,9 @@ def get_partial_permissions(self, object_permission): codename = object_permission.permission.codename if codename.startswith(PREFIX_PARTIAL_PERMS): view = self.context.get('view') - asset = view.asset + # if view doesn't have an `asset` property, + # fallback to context. + asset = getattr(view, 'asset', self.context.get('asset')) partial_perms = asset.get_partial_perms( object_permission.user_id, True) diff --git a/kpi/views/v2/asset.py b/kpi/views/v2/asset.py index c1278b6069..59ba4327a5 100644 --- a/kpi/views/v2/asset.py +++ b/kpi/views/v2/asset.py @@ -194,9 +194,6 @@ class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): ) def get_serializer_class(self): - # need to initialise `self.asset` for the serializer. - # Serializer needs `self.context.get('view').asset - self.asset = self.get_object() if self.action == 'list': return AssetListSerializer else: From 85ec8d6a73b50540bcbde8ef9cb854b071f8a870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 15 May 2019 09:39:46 -0400 Subject: [PATCH 052/499] Moved API tests to its own folders (v1 & v2) --- .../relative_prefix_hyperlinked_related.py | 2 - kpi/fields/versioned_hyperlinked_identity.py | 11 - kpi/fields/versioned_hyperlinked_related.py | 27 -- kpi/serializers/v2/asset_file.py | 2 +- kpi/serializers/v2/asset_permission.py | 23 +- kpi/serializers/v2/asset_version.py | 2 +- kpi/tests/api/v1/test_api_environment.py | 38 +++ kpi/tests/api/v1/test_api_users.py | 42 +++ kpi/tests/api/v2/__init__.py | 12 + kpi/tests/api/v2/test_api_asset_snapshots.py | 2 +- kpi/tests/api/v2/test_api_environment.py | 38 +++ kpi/tests/api/v2/test_api_imports.py | 156 +++++++++ kpi/tests/test_api_submissions.py | 298 ------------------ kpi/utils/url_helper.py | 61 ---- 14 files changed, 299 insertions(+), 415 deletions(-) delete mode 100644 kpi/fields/versioned_hyperlinked_identity.py delete mode 100644 kpi/fields/versioned_hyperlinked_related.py create mode 100644 kpi/tests/api/v1/test_api_environment.py create mode 100644 kpi/tests/api/v2/test_api_environment.py create mode 100644 kpi/tests/api/v2/test_api_imports.py delete mode 100644 kpi/tests/test_api_submissions.py delete mode 100644 kpi/utils/url_helper.py diff --git a/kpi/fields/relative_prefix_hyperlinked_related.py b/kpi/fields/relative_prefix_hyperlinked_related.py index 3c3d2009fc..85f9501855 100644 --- a/kpi/fields/relative_prefix_hyperlinked_related.py +++ b/kpi/fields/relative_prefix_hyperlinked_related.py @@ -4,8 +4,6 @@ from django.urls import get_script_prefix from rest_framework.serializers import HyperlinkedRelatedField -from .versioned_hyperlinked_related import VersionedHyperlinkedRelatedField - class RelativePrefixHyperlinkedRelatedField(HyperlinkedRelatedField): diff --git a/kpi/fields/versioned_hyperlinked_identity.py b/kpi/fields/versioned_hyperlinked_identity.py deleted file mode 100644 index 256e23465d..0000000000 --- a/kpi/fields/versioned_hyperlinked_identity.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework.serializers import HyperlinkedIdentityField -from .versioned_hyperlinked_related import VersionedHyperlinkedRelatedField - - -class VersionedHyperlinkedIdentityField(HyperlinkedIdentityField, - VersionedHyperlinkedRelatedField): - - pass diff --git a/kpi/fields/versioned_hyperlinked_related.py b/kpi/fields/versioned_hyperlinked_related.py deleted file mode 100644 index a44ae07584..0000000000 --- a/kpi/fields/versioned_hyperlinked_related.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from rest_framework.serializers import HyperlinkedRelatedField - -from kpi.utils.url_helper import UrlHelper - - -class VersionedHyperlinkedRelatedField(HyperlinkedRelatedField): - """ - Extends `DRF.HyperlinkedRelatedField` to support versions of kpi's API. - Can not use DRF native versioning classes because of the structure of - urls of V1. - """ - def get_url(self, obj, view_name, request, format): - # Unsaved objects will not yet have a valid URL. - if hasattr(obj, 'pk') and obj.pk in (None, ''): - return None - - lookup_value = getattr(obj, self.lookup_field) - kwargs = {self.lookup_url_kwarg: lookup_value} - - return UrlHelper.reverse(view_name, - kwargs=kwargs, - request=request, - context=self.context, - format=format) diff --git a/kpi/serializers/v2/asset_file.py b/kpi/serializers/v2/asset_file.py index 008f47dce1..719f4576f3 100644 --- a/kpi/serializers/v2/asset_file.py +++ b/kpi/serializers/v2/asset_file.py @@ -1,10 +1,10 @@ # coding: utf-8 from rest_framework import serializers +from rest_framework.reverse import reverse from kpi.fields import RelativePrefixHyperlinkedRelatedField, \ SerializerMethodFileField, WritableJSONField from kpi.models import AssetFile -from kpi.utils.url_helper import UrlHelper class AssetFileSerializer(serializers.ModelSerializer): diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 7901faa3e9..c653090c38 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -3,12 +3,12 @@ from django.contrib.auth.models import User from rest_framework import serializers +from rest_framework.reverse import reverse from kpi.constants import PREFIX_PARTIAL_PERMS from kpi.fields.relative_prefix_hyperlinked_related import \ RelativePrefixHyperlinkedRelatedField from kpi.models.object_permission import ObjectPermission -from kpi.utils.url_helper import UrlHelper class AssetPermissionSerializer(serializers.ModelSerializer): @@ -60,10 +60,9 @@ def get_permission(self, object_permission): def get_url(self, object_permission): asset_uid = self.context.get('asset_uid') - return UrlHelper.reverse('asset-permission-detail', - args=(asset_uid, object_permission.uid), - request=self.context.get('request', None), - context=self.context) + return reverse('asset-permission-detail', + args=(asset_uid, object_permission.uid), + request=self.context.get('request', None)) def to_representation(self, instance): """ @@ -77,14 +76,12 @@ def to_representation(self, instance): return repr_ def __get_permission_hyperlink(self, codename): - return UrlHelper.reverse('permission-detail', - args=(codename,), - request=self.context.get('request', None), - context=self.context) + return reverse('permission-detail', + args=(codename,), + request=self.context.get('request', None)) def __get_user_hyperlink(self, username): - return UrlHelper.reverse('user-detail', - args=(username,), - request=self.context.get('request', None), - context=self.context) + return reverse('user-detail', + args=(username,), + request=self.context.get('request', None)) diff --git a/kpi/serializers/v2/asset_version.py b/kpi/serializers/v2/asset_version.py index 25952a8f06..57d080e101 100644 --- a/kpi/serializers/v2/asset_version.py +++ b/kpi/serializers/v2/asset_version.py @@ -1,8 +1,8 @@ # coding: utf-8 from rest_framework import serializers +from rest_framework.reverse import reverse from kpi.models import AssetVersion -from kpi.utils.url_helper import UrlHelper class AssetVersionListSerializer(serializers.Serializer): diff --git a/kpi/tests/api/v1/test_api_environment.py b/kpi/tests/api/v1/test_api_environment.py new file mode 100644 index 0000000000..9bc6f23630 --- /dev/null +++ b/kpi/tests/api/v1/test_api_environment.py @@ -0,0 +1,38 @@ +import constance +from django.http import HttpRequest +from django.core.urlresolvers import reverse +from django.template import Template, RequestContext +from rest_framework import status +from rest_framework.test import APITestCase + + +class EnvironmentTests(APITestCase): + fixtures = ['test_data'] + + def setUp(self): + self.url = reverse('environment') + self.expected_dict = { + 'terms_of_service_url': constance.config.TERMS_OF_SERVICE_URL, + 'privacy_policy_url': constance.config.PRIVACY_POLICY_URL, + 'source_code_url': constance.config.SOURCE_CODE_URL, + 'support_url': constance.config.SUPPORT_URL, + 'support_email': constance.config.SUPPORT_EMAIL, + } + + def test_anonymous_succeeds(self): + response = self.client.get(self.url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data, self.expected_dict) + + def test_authenticated_succeeds(self): + self.client.login(username='admin', password='pass') + response = self.client.get(self.url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data, self.expected_dict) + + def test_template_context_processor(self): + ''' Not an API test, but hey: nevermind the hobgoblins ''' + context = RequestContext(HttpRequest()) # NB: empty request + template = Template('{{ config.TERMS_OF_SERVICE_URL }}') + result = template.render(context) + self.assertEqual(result, constance.config.TERMS_OF_SERVICE_URL) diff --git a/kpi/tests/api/v1/test_api_users.py b/kpi/tests/api/v1/test_api_users.py index 2a5953cbbe..c6b96aab48 100644 --- a/kpi/tests/api/v1/test_api_users.py +++ b/kpi/tests/api/v1/test_api_users.py @@ -1,7 +1,49 @@ # coding: utf-8 + from kpi.tests.api.v2 import test_api_users +from kpi.tests.base_test_case import BaseTestCase +from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE class UserListTests(test_api_users.UserListTests): URL_NAMESPACE = None + + + +class UserListTests(BaseTestCase): + fixtures = ['test_data'] + + URL_NAMESPACE = ROUTER_URL_NAMESPACE + + def setUp(self): + self.client.login(username='admin', password='pass') + + def test_user_list_forbidden(self): + """ + we cannot query the entire user list + """ + url = reverse(self._get_endpoint('user-list')) + response = self.client.get(url, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_user_page_succeeds(self): + """ + we can retrieve user details + """ + username = 'admin' + url = reverse(self._get_endpoint('user-detail'), args=[username]) + response = self.client.get(url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('username', response.data) + self.assertEqual(response.data['username'], username) + + def test_invalid_user_fails(self): + """ + verify that a 404 is returned when trying to retrieve details for an + invalid user + """ + url = reverse(self._get_endpoint('user-detail'), args=['nonexistentuser']) + response = self.client.get(url, format='json') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + diff --git a/kpi/tests/api/v2/__init__.py b/kpi/tests/api/v2/__init__.py index e69de29bb2..659c5e3b62 100644 --- a/kpi/tests/api/v2/__init__.py +++ b/kpi/tests/api/v2/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + + +class VersioningTestMixin: + + def _get_endpoint(self, endpoint): + if hasattr(self, 'URL_NAMESPACE') and self.URL_NAMESPACE is not None: + endpoint = '{}:{}'.format(self.URL_NAMESPACE, endpoint) \ + if self.URL_NAMESPACE else endpoint + print(endpoint) + return endpoint diff --git a/kpi/tests/api/v2/test_api_asset_snapshots.py b/kpi/tests/api/v2/test_api_asset_snapshots.py index 89ad2370ac..375da436dd 100644 --- a/kpi/tests/api/v2/test_api_asset_snapshots.py +++ b/kpi/tests/api/v2/test_api_asset_snapshots.py @@ -11,7 +11,7 @@ from kpi.utils.strings import to_str -class TestAssetSnapshotList(KpiTestCase): +class TestAssetSnapshotList(VersioningTestMixin, KpiTestCase): fixtures = ['test_data'] URL_NAMESPACE = ROUTER_URL_NAMESPACE diff --git a/kpi/tests/api/v2/test_api_environment.py b/kpi/tests/api/v2/test_api_environment.py new file mode 100644 index 0000000000..9bc6f23630 --- /dev/null +++ b/kpi/tests/api/v2/test_api_environment.py @@ -0,0 +1,38 @@ +import constance +from django.http import HttpRequest +from django.core.urlresolvers import reverse +from django.template import Template, RequestContext +from rest_framework import status +from rest_framework.test import APITestCase + + +class EnvironmentTests(APITestCase): + fixtures = ['test_data'] + + def setUp(self): + self.url = reverse('environment') + self.expected_dict = { + 'terms_of_service_url': constance.config.TERMS_OF_SERVICE_URL, + 'privacy_policy_url': constance.config.PRIVACY_POLICY_URL, + 'source_code_url': constance.config.SOURCE_CODE_URL, + 'support_url': constance.config.SUPPORT_URL, + 'support_email': constance.config.SUPPORT_EMAIL, + } + + def test_anonymous_succeeds(self): + response = self.client.get(self.url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data, self.expected_dict) + + def test_authenticated_succeeds(self): + self.client.login(username='admin', password='pass') + response = self.client.get(self.url, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data, self.expected_dict) + + def test_template_context_processor(self): + ''' Not an API test, but hey: nevermind the hobgoblins ''' + context = RequestContext(HttpRequest()) # NB: empty request + template = Template('{{ config.TERMS_OF_SERVICE_URL }}') + result = template.render(context) + self.assertEqual(result, constance.config.TERMS_OF_SERVICE_URL) diff --git a/kpi/tests/api/v2/test_api_imports.py b/kpi/tests/api/v2/test_api_imports.py new file mode 100644 index 0000000000..86c5b724f6 --- /dev/null +++ b/kpi/tests/api/v2/test_api_imports.py @@ -0,0 +1,156 @@ +import base64 +import unittest +import requests +import responses + +from rest_framework import status +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase +from django.conf import settings +from django.contrib.auth.models import User +from django.db import transaction + +from ..models import Asset + + +class AssetImportTaskTest(APITestCase): + fixtures = ['test_data'] + + def setUp(self): + self.client.login(username='someuser', password='someuser') + self.user = User.objects.get(username='someuser') + self.asset = Asset.objects.first() + settings.CELERY_TASK_ALWAYS_EAGER = True + + def _assert_assets_contents_equal(self, a1, a2): + def _prep_row_for_comparison(row): + row = {k: v for k, v in row.items() if not k.startswith('$')} + if isinstance(row['label'], list) and len(row['label']) == 1: + row['label'] = row['label'][0] + return row + self.assertEqual(len(a1.content['survey']), len(a2.content['survey'])) + for index, row in enumerate(a1.content['survey']): + expected_row = _prep_row_for_comparison(row) + result_row = _prep_row_for_comparison(a2.content['survey'][index]) + self.assertDictEqual(result_row, expected_row) + + def _post_import_task_and_compare_created_asset_to_source(self, task_data, + source): + post_url = reverse('importtask-list') + response = self.client.post(post_url, task_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` + detail_response = self.client.get(response.data['url']) + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data['status'], 'complete') + created_details = detail_response.data['messages']['created'][0] + self.assertEqual(created_details['kind'], 'asset') + # Check the resulting asset + created_asset = Asset.objects.get(uid=created_details['uid']) + self.assertEqual(created_asset.name, task_data['name']) + self._assert_assets_contents_equal(created_asset, source) + + @responses.activate + def test_import_asset_from_xls_url(self): + # Host the XLS on a mock HTTP server + mock_xls_url = 'http://mock.kbtdev.org/form.xls' + responses.add(responses.GET, mock_xls_url, + content_type='application/xls', + body=self.asset.to_xls_io().read()) + task_data = { + 'url': mock_xls_url, + 'name': 'I was imported via URL!', + } + self._post_import_task_and_compare_created_asset_to_source(task_data, + self.asset) + + def test_import_asset_base64_xls(self): + encoded_xls = base64.b64encode(self.asset.to_xls_io().read()) + task_data = { + 'base64Encoded': 'base64:' + encoded_xls, + 'name': 'I was imported via base64-encoded XLS!', + } + self._post_import_task_and_compare_created_asset_to_source(task_data, + self.asset) + + def test_import_asset_xls(self): + xls_io = self.asset.to_xls_io() + task_data = { + 'file': xls_io, + 'name': 'I was imported via XLS!', + } + self._post_import_task_and_compare_created_asset_to_source(task_data, + self.asset) + + def test_import_non_xls_url(self): + ''' Make sure the import fails with a meaningful error ''' + task_data = { + 'url': 'https://www.google.com/', + 'name': 'I was doomed from the start! (non-XLS)', + } + post_url = reverse('importtask-list') + response = self.client.post(post_url, task_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` + detail_response = self.client.get(response.data['url']) + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data['status'], 'error') + self.assertTrue( + detail_response.data['messages']['error'].startswith( + 'Error reading .xls file: Unsupported format' + ) + ) + + @unittest.skip + def test_import_invalid_host_url(self): + ''' Make sure the import fails with a meaningful error ''' + task_data = { + 'url': 'https://invalid-host-test.u6Bqpwgms2/', + 'name': 'I was doomed from the start! (invalid hostname)', + } + with transaction.atomic(): + # transaction avoids TransactionManagementError + post_url = reverse('importtask-list') + response = self.client.post(post_url, task_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` + detail_response = self.client.get(response.data['url']) + # FIXME: this fails because the detail request returns a 404, even + # after the POST returns a 201! + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + + def test_import_xls_with_default_language_but_no_translations(self): + xls_io = self.asset.to_xls_io(append={"settings": {"default_language": "English (en)"}}) + task_data = { + 'file': xls_io, + 'name': 'I was imported via XLS!', + } + post_url = reverse('importtask-list') + response = self.client.post(post_url, task_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + detail_response = self.client.get(response.data['url']) + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data['status'], 'complete') + + def test_import_xls_with_default_language_not_in_translations(self): + asset = Asset.objects.get(pk=2) + xls_io = asset.to_xls_io(append={ + "settings": {"default_language": "English (en)"} + }) + task_data = { + 'file': xls_io, + 'name': 'I was imported via XLS!', + } + post_url = reverse('importtask-list') + response = self.client.post(post_url, task_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` + detail_response = self.client.get(response.data['url']) + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data['status'], 'error') + self.assertTrue( + detail_response.data['messages']['error'].startswith( + "`English (en)` is specified as the default language, " + "but only these translations are present in the form:" + ) + ) diff --git a/kpi/tests/test_api_submissions.py b/kpi/tests/test_api_submissions.py deleted file mode 100644 index d6c3dad755..0000000000 --- a/kpi/tests/test_api_submissions.py +++ /dev/null @@ -1,298 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -import json -import requests -import responses - -from django.conf import settings -from django.contrib.auth import get_user -from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -from rest_framework import status -from rest_framework.test import APITestCase - -from kpi.models import Asset -from kpi.constants import INSTANCE_FORMAT_TYPE_JSON, PERM_VIEW_SUBMISSIONS,\ - PERM_PARTIAL_SUBMISSIONS -from .kpi_test_case import KpiTestCase - - -class BaseTestCase(APITestCase): - fixtures = ["test_data"] - - """ - SubmissionViewset uses `BrowsableAPIRenderer` as the first renderer. - Force JSON to test the API by specifying `format`, `HTTP_ACCEPT` or - `content_type` - """ - - def setUp(self): - self.client.login(username="someuser", password="someuser") - self.someuser = User.objects.get(username="someuser") - self.anotheruser = User.objects.get(username="anotheruser") - asset_template = Asset.objects.get(id=1) - self.asset = Asset.objects.create(content=asset_template.content, - owner=self.someuser, - asset_type='survey') - - self.asset.deploy(backend='mock', active=True) - self.asset.save() - - v_uid = self.asset.latest_deployed_version.uid - self.submissions = [ - { - "__version__": v_uid, - "q1": "a1", - "q2": "a2", - "id": 1, - "_validation_status": { - "by_whom": "someuser", - "timestamp": 1547839938, - "uid": "validation_status_on_hold", - "color": "#0000ff", - "label": "On Hold" - }, - "submitted_by": "" - }, - { - "__version__": v_uid, - "q1": "a3", - "q2": "a4", - "id": 2, - "_validation_status": { - "by_whom": "someuser", - "timestamp": 1547839938, - "uid": "validation_status_approved", - "color": "#0000ff", - "label": "On Hold" - }, - "submitted_by": "someuser" - } - ] - self.asset.deployment.mock_submissions(self.submissions) - self.submission_url = self.asset.deployment.submission_list_url - - def _other_user_login(self, shared_asset=False): - self.client.logout() - self.client.login(username="anotheruser", password="anotheruser") - if shared_asset: - self.asset.assign_perm(self.anotheruser, PERM_VIEW_SUBMISSIONS) - - -class SubmissionApiTests(BaseTestCase): - - def test_create_submission(self): - v_uid = self.asset.latest_deployed_version.uid - submission = { - "q1": "a5", - "q2": "a6", - } - # Owner - response = self.client.post(self.submission_url, data=submission) - self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) - - # Shared - self._other_user_login(True) - response = self.client.post(self.submission_url, data=submission) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - # Anonymous - self.client.logout() - response = self.client.post(self.submission_url, data=submission) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_list_submissions_owner(self): - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self.submissions) - - def test_list_submissions_not_shared_other(self): - self._other_user_login() - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_list_submissions_shared_other(self): - self._other_user_login(True) - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self.submissions) - - def test_list_submissions_with_partial_permissions(self): - self._other_user_login() - partial_perms = { - PERM_VIEW_SUBMISSIONS: [self.someuser.username] - } - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - self.asset.assign_perm(self.anotheruser, PERM_PARTIAL_SUBMISSIONS, - partial_perms=partial_perms) - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(self.asset.deployment.submission_count == 2) - # User `anotheruser` should only see submissions where `submitted_by` - # is filled up and equals to `someuser` - self.assertTrue(len(response.data) == 1) - - def test_list_submissions_anonymous(self): - self.client.logout() - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_retrieve_submission_owner(self): - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - - response = self.client.get(url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, submission) - - def test_retrieve_submission_not_shared_other(self): - self._other_user_login() - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - response = self.client.get(url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_retrieve_submission_shared_other(self): - self._other_user_login(True) - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - response = self.client.get(url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, submission) - - def test_retrieve_submission_with_partial_permissions(self): - self._other_user_login() - partial_perms = { - PERM_VIEW_SUBMISSIONS: [self.someuser.username] - } - self.asset.assign_perm(self.anotheruser, PERM_PARTIAL_SUBMISSIONS, - partial_perms=partial_perms) - - # Try first submission submitted by unknown - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - response = self.client.get(url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - # Try second submission submitted by someuser - submission = self.submissions[1] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - response = self.client.get(url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_delete_submission_owner(self): - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - - response = self.client.delete(url, - content_type="application/json", - HTTP_ACCEPT="application/json") - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - def test_delete_submission_anonymous(self): - self.client.logout() - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - - response = self.client.delete(url, - content_type="application/json", - HTTP_ACCEPT="application/json") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete_submission_not_shared_other(self): - self._other_user_login() - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - - response = self.client.delete(url, - content_type="application/json", - HTTP_ACCEPT="application/json") - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete_submission_shared_other_no_write(self): - self._other_user_login(True) - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - response = self.client.delete(url, - content_type="application/json", - HTTP_ACCEPT="application/json") - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_delete_submission_shared_other_write(self): - self._other_user_login(True) - self.asset.assign_perm(self.anotheruser, "change_submissions") - submission = self.submissions[0] - url = self.asset.deployment.get_submission_detail_url(submission.get("id")) - response = self.client.delete(url, - content_type="application/json", - HTTP_ACCEPT="application/json") - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - -class SubmissionEditApiTests(BaseTestCase): - - def setUp(self): - super(SubmissionEditApiTests, self).setUp() - self.submission = self.submissions[0] - self.submission_url = reverse("submission-edit", kwargs={ - "parent_lookup_asset": self.asset.uid, - "pk": self.submission.get("id") - }) - - def test_trigger_signal(self): - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - expected_response = { - "url": "http://server.mock/enketo/{}".format(self.submission.get("id")) - } - self.assertEqual(response.data, expected_response) - - def test_get_edit_link_submission_anonymous(self): - self.client.logout() - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_get_edit_link_submission_shared_other(self): - self._other_user_login() - response = self.client.get(self.submission_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -class SubmissionValidationStatusApiTests(BaseTestCase): - - # @TODO Test PATCH - - def setUp(self): - super(SubmissionValidationStatusApiTests, self).setUp() - self.submission = self.submissions[0] - self.validation_status_url = self.asset.deployment.get_submission_validation_status_url( - self.submission.get("id")) - - def test_submission_validate_status_owner(self): - response = self.client.get(self.validation_status_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self.submission.get("_validation_status")) - - def test_submission_validate_status_not_shared_other(self): - self._other_user_login() - response = self.client.get(self.validation_status_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_submission_validate_status_other(self): - self._other_user_login(True) - response = self.client.get(self.validation_status_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self.submission.get("_validation_status")) - - def test_submission_validate_status_anonymous(self): - self.client.logout() - response = self.client.get(self.validation_status_url, {"format": "json"}) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -class SubmissionRestrictedApiTests(BaseTestCase): - pass diff --git a/kpi/utils/url_helper.py b/kpi/utils/url_helper.py deleted file mode 100644 index a63fd8ced7..0000000000 --- a/kpi/utils/url_helper.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -from rest_framework.reverse import reverse, reverse_lazy - - -class UrlHelper: - - def __init__(self): - pass - - @staticmethod - def reverse(viewname, *args, **kwargs): - """ - Has the same behavior as `rest_framework.reverse.reverse`, - except it adds namespace if needed. - - :param viewname: str - :param namespace: str - :return: str - """ - context = kwargs.pop('context', None) - namespace = None - - if context: - try: - namespace = context.get('view').URL_NAMESPACE - except AttributeError: - pass - - if namespace is not None: - viewname = '{namespace}:{viewname}'.format( - namespace=namespace, - viewname=viewname - ) - - return reverse(viewname, *args, **kwargs) - - @staticmethod - def reverse_lazy(viewname, *args, **kwargs): - """ - Has the same behavior as `rest_framework.reverse.reverse_lazy`, - except it adds namespace if needed. - - :param viewname: str - :param namespace: str - :return: str - """ - context = kwargs.get('context') - namespace = None - if context: - namespace = getattr(context.get('view'), 'URL_NAMESPACE') - - if namespace is not None: - viewname = '{namespace}:{viewname}'.format( - namespace=namespace, - viewname=viewname - ) - - return reverse_lazy(viewname, *args, **kwargs) - From 977ad188265149ee3313ce30a2da54add067df1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 15 May 2019 10:48:57 -0400 Subject: [PATCH 053/499] WIP tests for v2 --- kpi/tests/api/v1/test_api_environment.py | 38 ------ kpi/tests/api/v2/__init__.py | 2 + kpi/tests/api/v2/test_api_assets.py | 1 + kpi/tests/api/v2/test_api_environment.py | 38 ------ kpi/tests/api/v2/test_api_imports.py | 156 ----------------------- kpi/tests/api/v2/test_api_permissions.py | 2 +- 6 files changed, 4 insertions(+), 233 deletions(-) delete mode 100644 kpi/tests/api/v1/test_api_environment.py delete mode 100644 kpi/tests/api/v2/test_api_environment.py delete mode 100644 kpi/tests/api/v2/test_api_imports.py diff --git a/kpi/tests/api/v1/test_api_environment.py b/kpi/tests/api/v1/test_api_environment.py deleted file mode 100644 index 9bc6f23630..0000000000 --- a/kpi/tests/api/v1/test_api_environment.py +++ /dev/null @@ -1,38 +0,0 @@ -import constance -from django.http import HttpRequest -from django.core.urlresolvers import reverse -from django.template import Template, RequestContext -from rest_framework import status -from rest_framework.test import APITestCase - - -class EnvironmentTests(APITestCase): - fixtures = ['test_data'] - - def setUp(self): - self.url = reverse('environment') - self.expected_dict = { - 'terms_of_service_url': constance.config.TERMS_OF_SERVICE_URL, - 'privacy_policy_url': constance.config.PRIVACY_POLICY_URL, - 'source_code_url': constance.config.SOURCE_CODE_URL, - 'support_url': constance.config.SUPPORT_URL, - 'support_email': constance.config.SUPPORT_EMAIL, - } - - def test_anonymous_succeeds(self): - response = self.client.get(self.url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.data, self.expected_dict) - - def test_authenticated_succeeds(self): - self.client.login(username='admin', password='pass') - response = self.client.get(self.url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.data, self.expected_dict) - - def test_template_context_processor(self): - ''' Not an API test, but hey: nevermind the hobgoblins ''' - context = RequestContext(HttpRequest()) # NB: empty request - template = Template('{{ config.TERMS_OF_SERVICE_URL }}') - result = template.render(context) - self.assertEqual(result, constance.config.TERMS_OF_SERVICE_URL) diff --git a/kpi/tests/api/v2/__init__.py b/kpi/tests/api/v2/__init__.py index 659c5e3b62..e432511c0b 100644 --- a/kpi/tests/api/v2/__init__.py +++ b/kpi/tests/api/v2/__init__.py @@ -4,6 +4,8 @@ class VersioningTestMixin: + URL_NAMESPACE = None + def _get_endpoint(self, endpoint): if hasattr(self, 'URL_NAMESPACE') and self.URL_NAMESPACE is not None: endpoint = '{}:{}'.format(self.URL_NAMESPACE, endpoint) \ diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index 7a7dd1a231..aed55693e9 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -232,6 +232,7 @@ def setUp(self): self.r = self.client.post(url, data, format='json') self.asset = Asset.objects.get(uid=self.r.data.get('uid')) self.asset_url = self.r.data['url'] + print("ASSET URL {}".format(self.asset_url)) self.assertEqual(self.r.status_code, status.HTTP_201_CREATED) self.asset_uid = self.r.data['uid'] diff --git a/kpi/tests/api/v2/test_api_environment.py b/kpi/tests/api/v2/test_api_environment.py deleted file mode 100644 index 9bc6f23630..0000000000 --- a/kpi/tests/api/v2/test_api_environment.py +++ /dev/null @@ -1,38 +0,0 @@ -import constance -from django.http import HttpRequest -from django.core.urlresolvers import reverse -from django.template import Template, RequestContext -from rest_framework import status -from rest_framework.test import APITestCase - - -class EnvironmentTests(APITestCase): - fixtures = ['test_data'] - - def setUp(self): - self.url = reverse('environment') - self.expected_dict = { - 'terms_of_service_url': constance.config.TERMS_OF_SERVICE_URL, - 'privacy_policy_url': constance.config.PRIVACY_POLICY_URL, - 'source_code_url': constance.config.SOURCE_CODE_URL, - 'support_url': constance.config.SUPPORT_URL, - 'support_email': constance.config.SUPPORT_EMAIL, - } - - def test_anonymous_succeeds(self): - response = self.client.get(self.url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.data, self.expected_dict) - - def test_authenticated_succeeds(self): - self.client.login(username='admin', password='pass') - response = self.client.get(self.url, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(response.data, self.expected_dict) - - def test_template_context_processor(self): - ''' Not an API test, but hey: nevermind the hobgoblins ''' - context = RequestContext(HttpRequest()) # NB: empty request - template = Template('{{ config.TERMS_OF_SERVICE_URL }}') - result = template.render(context) - self.assertEqual(result, constance.config.TERMS_OF_SERVICE_URL) diff --git a/kpi/tests/api/v2/test_api_imports.py b/kpi/tests/api/v2/test_api_imports.py deleted file mode 100644 index 86c5b724f6..0000000000 --- a/kpi/tests/api/v2/test_api_imports.py +++ /dev/null @@ -1,156 +0,0 @@ -import base64 -import unittest -import requests -import responses - -from rest_framework import status -from rest_framework.reverse import reverse -from rest_framework.test import APITestCase -from django.conf import settings -from django.contrib.auth.models import User -from django.db import transaction - -from ..models import Asset - - -class AssetImportTaskTest(APITestCase): - fixtures = ['test_data'] - - def setUp(self): - self.client.login(username='someuser', password='someuser') - self.user = User.objects.get(username='someuser') - self.asset = Asset.objects.first() - settings.CELERY_TASK_ALWAYS_EAGER = True - - def _assert_assets_contents_equal(self, a1, a2): - def _prep_row_for_comparison(row): - row = {k: v for k, v in row.items() if not k.startswith('$')} - if isinstance(row['label'], list) and len(row['label']) == 1: - row['label'] = row['label'][0] - return row - self.assertEqual(len(a1.content['survey']), len(a2.content['survey'])) - for index, row in enumerate(a1.content['survey']): - expected_row = _prep_row_for_comparison(row) - result_row = _prep_row_for_comparison(a2.content['survey'][index]) - self.assertDictEqual(result_row, expected_row) - - def _post_import_task_and_compare_created_asset_to_source(self, task_data, - source): - post_url = reverse('importtask-list') - response = self.client.post(post_url, task_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` - detail_response = self.client.get(response.data['url']) - self.assertEqual(detail_response.status_code, status.HTTP_200_OK) - self.assertEqual(detail_response.data['status'], 'complete') - created_details = detail_response.data['messages']['created'][0] - self.assertEqual(created_details['kind'], 'asset') - # Check the resulting asset - created_asset = Asset.objects.get(uid=created_details['uid']) - self.assertEqual(created_asset.name, task_data['name']) - self._assert_assets_contents_equal(created_asset, source) - - @responses.activate - def test_import_asset_from_xls_url(self): - # Host the XLS on a mock HTTP server - mock_xls_url = 'http://mock.kbtdev.org/form.xls' - responses.add(responses.GET, mock_xls_url, - content_type='application/xls', - body=self.asset.to_xls_io().read()) - task_data = { - 'url': mock_xls_url, - 'name': 'I was imported via URL!', - } - self._post_import_task_and_compare_created_asset_to_source(task_data, - self.asset) - - def test_import_asset_base64_xls(self): - encoded_xls = base64.b64encode(self.asset.to_xls_io().read()) - task_data = { - 'base64Encoded': 'base64:' + encoded_xls, - 'name': 'I was imported via base64-encoded XLS!', - } - self._post_import_task_and_compare_created_asset_to_source(task_data, - self.asset) - - def test_import_asset_xls(self): - xls_io = self.asset.to_xls_io() - task_data = { - 'file': xls_io, - 'name': 'I was imported via XLS!', - } - self._post_import_task_and_compare_created_asset_to_source(task_data, - self.asset) - - def test_import_non_xls_url(self): - ''' Make sure the import fails with a meaningful error ''' - task_data = { - 'url': 'https://www.google.com/', - 'name': 'I was doomed from the start! (non-XLS)', - } - post_url = reverse('importtask-list') - response = self.client.post(post_url, task_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` - detail_response = self.client.get(response.data['url']) - self.assertEqual(detail_response.status_code, status.HTTP_200_OK) - self.assertEqual(detail_response.data['status'], 'error') - self.assertTrue( - detail_response.data['messages']['error'].startswith( - 'Error reading .xls file: Unsupported format' - ) - ) - - @unittest.skip - def test_import_invalid_host_url(self): - ''' Make sure the import fails with a meaningful error ''' - task_data = { - 'url': 'https://invalid-host-test.u6Bqpwgms2/', - 'name': 'I was doomed from the start! (invalid hostname)', - } - with transaction.atomic(): - # transaction avoids TransactionManagementError - post_url = reverse('importtask-list') - response = self.client.post(post_url, task_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` - detail_response = self.client.get(response.data['url']) - # FIXME: this fails because the detail request returns a 404, even - # after the POST returns a 201! - self.assertEqual(detail_response.status_code, status.HTTP_200_OK) - - def test_import_xls_with_default_language_but_no_translations(self): - xls_io = self.asset.to_xls_io(append={"settings": {"default_language": "English (en)"}}) - task_data = { - 'file': xls_io, - 'name': 'I was imported via XLS!', - } - post_url = reverse('importtask-list') - response = self.client.post(post_url, task_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - detail_response = self.client.get(response.data['url']) - self.assertEqual(detail_response.status_code, status.HTTP_200_OK) - self.assertEqual(detail_response.data['status'], 'complete') - - def test_import_xls_with_default_language_not_in_translations(self): - asset = Asset.objects.get(pk=2) - xls_io = asset.to_xls_io(append={ - "settings": {"default_language": "English (en)"} - }) - task_data = { - 'file': xls_io, - 'name': 'I was imported via XLS!', - } - post_url = reverse('importtask-list') - response = self.client.post(post_url, task_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - # Task should complete right away due to `CELERY_TASK_ALWAYS_EAGER` - detail_response = self.client.get(response.data['url']) - self.assertEqual(detail_response.status_code, status.HTTP_200_OK) - self.assertEqual(detail_response.data['status'], 'error') - self.assertTrue( - detail_response.data['messages']['error'].startswith( - "`English (en)` is specified as the default language, " - "but only these translations are present in the form:" - ) - ) diff --git a/kpi/tests/api/v2/test_api_permissions.py b/kpi/tests/api/v2/test_api_permissions.py index aff2a413ce..12da9b4722 100644 --- a/kpi/tests/api/v2/test_api_permissions.py +++ b/kpi/tests/api/v2/test_api_permissions.py @@ -117,7 +117,7 @@ def test_revoke_anon_from_asset_in_public_collection(self): self.assert_viewable(child_asset, viewable=False) -class ApiPermissionsTestCase(KpiTestCase): +class ApiPermissionsTestCase(VersioningTestMixin, KpiTestCase): fixtures = ['test_data'] URL_NAMESPACE = ROUTER_URL_NAMESPACE From 521abd2051368770d2762387ae5d9e13e153c5f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 15 May 2019 17:00:43 -0400 Subject: [PATCH 054/499] Fixed v2 tests --- kpi/views/v1/hook_signal.py | 2 +- kpi/views/v2/asset_permission.py | 2 -- kpi/views/v2/hook_signal.py | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/kpi/views/v1/hook_signal.py b/kpi/views/v1/hook_signal.py index eba5456946..d490c9fff7 100644 --- a/kpi/views/v1/hook_signal.py +++ b/kpi/views/v1/hook_signal.py @@ -32,4 +32,4 @@ class HookSignalViewSet(HookSignalViewSetV2): """ - URL_NAMESPACE = None + pass diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 74213a2f16..fef7fcc6b0 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -18,8 +18,6 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, ### CURRENT ENDPOINT """ - URL_NAMESPACE = 'api_v2' - model = ObjectPermission lookup_field = "uid" serializer_class = AssetPermissionSerializer diff --git a/kpi/views/v2/hook_signal.py b/kpi/views/v2/hook_signal.py index 441b8b8267..ef2eab9aa5 100644 --- a/kpi/views/v2/hook_signal.py +++ b/kpi/views/v2/hook_signal.py @@ -37,8 +37,6 @@ class HookSignalViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, """ - URL_NAMESPACE = 'api_v2' - parent_model = Asset def create(self, request, *args, **kwargs): From 97a5894001e8363b8d62206f8ed6fe16f5bbd1b5 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 16 May 2019 19:49:35 -0400 Subject: [PATCH 055/499] Fixed namespaced urls issues in unittests --- kpi/tests/api/v2/__init__.py | 14 -------------- kpi/tests/api/v2/test_api_asset_snapshots.py | 2 +- kpi/tests/api/v2/test_api_assets.py | 2 -- kpi/tests/api/v2/test_api_permissions.py | 2 +- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/kpi/tests/api/v2/__init__.py b/kpi/tests/api/v2/__init__.py index e432511c0b..e69de29bb2 100644 --- a/kpi/tests/api/v2/__init__.py +++ b/kpi/tests/api/v2/__init__.py @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - - -class VersioningTestMixin: - - URL_NAMESPACE = None - - def _get_endpoint(self, endpoint): - if hasattr(self, 'URL_NAMESPACE') and self.URL_NAMESPACE is not None: - endpoint = '{}:{}'.format(self.URL_NAMESPACE, endpoint) \ - if self.URL_NAMESPACE else endpoint - print(endpoint) - return endpoint diff --git a/kpi/tests/api/v2/test_api_asset_snapshots.py b/kpi/tests/api/v2/test_api_asset_snapshots.py index 375da436dd..89ad2370ac 100644 --- a/kpi/tests/api/v2/test_api_asset_snapshots.py +++ b/kpi/tests/api/v2/test_api_asset_snapshots.py @@ -11,7 +11,7 @@ from kpi.utils.strings import to_str -class TestAssetSnapshotList(VersioningTestMixin, KpiTestCase): +class TestAssetSnapshotList(KpiTestCase): fixtures = ['test_data'] URL_NAMESPACE = ROUTER_URL_NAMESPACE diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index aed55693e9..af298aa2ac 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -172,7 +172,6 @@ def uids_from_search_results(query): results = uids_from_search_results('pk:alrighty') self.assertListEqual(results, []) - class AssetVersionApiTests(BaseTestCase): fixtures = ['test_data'] @@ -465,7 +464,6 @@ def test_assignable_permissions(self): self.assertEqual(assignable_perm['url'], expected_response[index]['url']) self.assertEqual(assignable_perm['label'], expected_response[index]['label']) - class AssetsXmlExportApiTests(KpiTestCase): # @TODO Migrate to v2 diff --git a/kpi/tests/api/v2/test_api_permissions.py b/kpi/tests/api/v2/test_api_permissions.py index 12da9b4722..aff2a413ce 100644 --- a/kpi/tests/api/v2/test_api_permissions.py +++ b/kpi/tests/api/v2/test_api_permissions.py @@ -117,7 +117,7 @@ def test_revoke_anon_from_asset_in_public_collection(self): self.assert_viewable(child_asset, viewable=False) -class ApiPermissionsTestCase(VersioningTestMixin, KpiTestCase): +class ApiPermissionsTestCase(KpiTestCase): fixtures = ['test_data'] URL_NAMESPACE = ROUTER_URL_NAMESPACE From 069eabe95fe666cd35bcb411e777ae0fdf7c58cd Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 16 May 2019 21:53:52 -0400 Subject: [PATCH 056/499] Use query filters instead of usernames in partial permissions --- kpi/deployment_backends/mock_backend.py | 3 ++- kpi/models/asset.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py index e0e3d3aa6f..f765c0e057 100644 --- a/kpi/deployment_backends/mock_backend.py +++ b/kpi/deployment_backends/mock_backend.py @@ -199,7 +199,8 @@ def get_submissions(self, requesting_user_id, if 'limit' in params: submissions = submissions[:params['limit']] - if submitted_by: + if permission_filters: + submitted_by = [k.get('_submitted_by') for k in permission_filters] if format_type == INSTANCE_FORMAT_TYPE_XML: # TODO handle `submitted_by` too. pass diff --git a/kpi/models/asset.py b/kpi/models/asset.py index cf380d3183..c0ca9958a8 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -846,10 +846,10 @@ def get_partial_perms( else: return list(perms) - def get_usernames_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): + def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): """ - Returns the list of usernames for a specfic permission `perm` - and this specific asset. + Returns the list of filters for a specfic permission `perm` + and this specific asset. This :param user_obj: auth.User :param perm: see `constants.*_SUBMISSIONS` :return: @@ -859,7 +859,7 @@ def get_usernames_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): raise BadPermissionsException("Only global permissions for " "submissions are supported.") - perms = self.get_partial_perms(user_id, True) + perms = self.get_partial_perms(user_id, with_filters=True) if perms: return perms.get(perm) return None From d9bdb09e304b9e6db272bccd744da77d19a1cc14 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Sat, 18 May 2019 10:17:50 -0400 Subject: [PATCH 057/499] Added CREATE, DELETE to AssetPermission viewset & serializer --- kpi/serializers/v2/asset_permission.py | 147 +++++++++++++++++++++---- kpi/views/v2/asset_permission.py | 25 +++-- 2 files changed, 143 insertions(+), 29 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index c653090c38..8caf27e1a8 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -1,13 +1,16 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import +from urlparse import urlparse -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User +from django.core.urlresolvers import resolve from rest_framework import serializers from rest_framework.reverse import reverse -from kpi.constants import PREFIX_PARTIAL_PERMS +from kpi.constants import PREFIX_PARTIAL_PERMS, SUFFIX_SUBMISSIONS_PERMS from kpi.fields.relative_prefix_hyperlinked_related import \ RelativePrefixHyperlinkedRelatedField +from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission @@ -20,7 +23,12 @@ class AssetPermissionSerializer(serializers.ModelSerializer): queryset=User.objects.all(), style={'base_template': 'input.html'} # Render as a simple text box ) - permission = serializers.SerializerMethodField() + permission = RelativePrefixHyperlinkedRelatedField( + view_name='permission-detail', + lookup_field='codename', + queryset=Permission.objects.all(), + style={'base_template': 'input.html'} # Render as a simple text box + ) partial_permissions = serializers.SerializerMethodField() class Meta: @@ -34,36 +42,113 @@ class Meta: read_only_fields = ('uid', ) + def create(self, validated_data): + user = validated_data['user'] + asset = validated_data['asset'] + permission = validated_data['permission'] + partial_permissions = validated_data.get('partial_permissions', None) + return asset.assign_perm(user, permission.codename, + partial_perms=partial_permissions) + def get_partial_permissions(self, object_permission): codename = object_permission.permission.codename if codename.startswith(PREFIX_PARTIAL_PERMS): view = self.context.get('view') # if view doesn't have an `asset` property, - # fallback to context. + # fallback to context. (e.g. AssetViewSet) asset = getattr(view, 'asset', self.context.get('asset')) partial_perms = asset.get_partial_perms( object_permission.user_id, True) - hyperlinked_partial_perms = {} - for perm_codename, users in partial_perms.items(): + hyperlinked_partial_perms = [] + for perm_codename, filters in partial_perms.items(): url = self.__get_permission_hyperlink(perm_codename) - hyperlinked_partial_perms[perm_codename] = { + hyperlinked_partial_perms.append({ 'url': url, - 'users': [self.__get_user_hyperlink(user) for user in users] - } + 'filters': filters + }) return hyperlinked_partial_perms return None - def get_permission(self, object_permission): - codename = object_permission.permission.codename - return self.__get_permission_hyperlink(codename) - def get_url(self, object_permission): asset_uid = self.context.get('asset_uid') return reverse('asset-permission-detail', args=(asset_uid, object_permission.uid), request=self.context.get('request', None)) + def validate(self, attrs): + # Because `partial_permissions` is a `SerializerMethodField`, + # it's read-only, so it's not validated nor added to `validated_data`. + # We need to do it manually + self.validate_partial_permissions(attrs) + return attrs + + def validate_partial_permissions(self, attrs): + """ + Validates permissions and filters sent with partial permissions. + + If data is valid, `partial_permissions` attribute is added to `attrs`. + Useful to permission assignment in `create()`. + + :param attrs: dict. + :return: dict. + """ + permission = attrs.get('permission') + request = self.context.get('request') + partial_permissions = request.data.get('partial_permissions') + partial_permissions_attr = {} + + if permission.codename.startswith(PREFIX_PARTIAL_PERMS): + if partial_permissions: + is_valid = True + try: + for partial_permission, filter_ in \ + self.__get_partial_permissions_generator(partial_permissions): + + parse_result = urlparse(partial_permission.get('url')) + resolver = resolve(parse_result.path) + codename = resolver.kwargs.get('codename') + # Permission must valid and must be assignable. + # Ensure `filter_` is a `dict`. + # No need to validate Mongo syntax, query will fail + # if syntax is not correct. + if (self._validate_permission(codename, + SUFFIX_SUBMISSIONS_PERMS) + and isinstance(filter_, dict)): + + if codename not in partial_permissions_attr: + partial_permissions_attr[codename] = [] + + partial_permissions_attr[codename].append(filter_) + continue + + is_valid = False + + except (AttributeError, ValueError): + is_valid = False + + if not is_valid: + raise serializers.ValidationError('Invalid partial permissions') + + # Everything went well. Add it to `attrs` + attrs.update({'partial_permissions': partial_permissions_attr}) + else: + raise serializers.ValidationError( + "Can not assign '{}' permission. Partial permissions " + "are missing.".format(permission.codename)) + + return attrs + + def validate_permission(self, permission): + """ + Checks if permission can be assigned on asset. + """ + if not self._validate_permission(permission.codename): + raise serializers.ValidationError( + '{} cannot be assigned explicitly to Asset objects.'.format( + permission.codename)) + return permission + def to_representation(self, instance): """ Doesn't display 'partial_permissions' attribute if it's `None`. @@ -75,13 +160,37 @@ def to_representation(self, instance): return repr_ + def _validate_permission(self, codename, suffix=None): + """ + Validates if `codename` can be assigned on `Asset`s. + Search can be restricted to assignable codenames which end with `prefix` + + :param codename: str. See `Asset.ASSIGNABLE_PERMISSIONS + :param suffix: str. + :return: bool. + """ + return (codename in Asset.get_assignable_permissions(with_partial=True) + and (suffix is None or codename.endswith(suffix))) + + def __get_partial_permissions_generator(self, partial_permissions): + """ + Creates a generator to iterate over partial_permissions list. + Useful to validate each item and stop iterating as soon as errors + are detected + + :param partial_permissions: list + :return: generator + """ + for partial_permission in partial_permissions: + for filter_ in partial_permission.get('filters'): + yield partial_permission, filter_ + def __get_permission_hyperlink(self, codename): + """ + Builds permission hyperlink representation. + :param codename: str + :return: str. url + """ return reverse('permission-detail', args=(codename,), request=self.context.get('request', None)) - - def __get_user_hyperlink(self, username): - return reverse('user-detail', - args=(username,), - request=self.context.get('request', None)) - diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index fef7fcc6b0..13e608d16e 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -2,19 +2,24 @@ from __future__ import absolute_import from rest_framework import viewsets, status +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ + DestroyModelMixin, ListModelMixin +from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin from kpi.models.object_permission import ObjectPermission from kpi.permissions import AssetNestedObjectPermission from kpi.serializers.v2.asset_permission import AssetPermissionSerializer -from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin from kpi.utils.object_permission_helper import ObjectPermissionHelper +from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, - viewsets.ModelViewSet): + CreateModelMixin, RetrieveModelMixin, + DestroyModelMixin, ListModelMixin, + viewsets.GenericViewSet): """ - + TODO documentation ### CURRENT ENDPOINT """ @@ -46,10 +51,10 @@ def list(self, request, *args, **kwargs): def perform_create(self, serializer): serializer.save(asset=self.asset) - #def perform_create(self, serializer): - # # Make sure the requesting user has the share_ permission on - # # the affected object - # codename = serializer.validated_data['permission'].codename - # if not self._requesting_user_can_share(affected_object, codename): - # raise exceptions.PermissionDenied() - # serializer.save() + def destroy(self, request, *args, **kwargs): + # TODO block owner's permission + object_permission = self.get_object() + user = object_permission.user + codename = object_permission.permission.codename + self.asset.remove_perm(user, codename) + return Response(status=status.HTTP_204_NO_CONTENT) From 0a818c1d8cdbbc5b87e86c51ea3c00a34057709e Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Sat, 18 May 2019 10:19:38 -0400 Subject: [PATCH 058/499] Renamed 'viewset_mixin.py' to 'viewset_mixins.py' to be compliant with DRF --- kobo/apps/hook/views/v2/hook.py | 48 ++++----------------------------- kpi/utils/viewset_mixin.py | 23 ---------------- kpi/views/v2/hook_signal.py | 2 +- 3 files changed, 6 insertions(+), 67 deletions(-) delete mode 100644 kpi/utils/viewset_mixin.py diff --git a/kobo/apps/hook/views/v2/hook.py b/kobo/apps/hook/views/v2/hook.py index b63c47770c..1ef9d1c5bd 100644 --- a/kobo/apps/hook/views/v2/hook.py +++ b/kobo/apps/hook/views/v2/hook.py @@ -14,58 +14,38 @@ from kobo.apps.hook.models import Hook, HookLog from kobo.apps.hook.serializers.v2.hook import HookSerializer from kobo.apps.hook.tasks import retry_all_task -<<<<<<< HEAD from kpi.permissions import AssetEditorSubmissionViewerPermission from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin -======= -from kpi.filters import AssetOwnerFilterBackend -from kpi.permissions import AssetOwnerNestedObjectPermission -from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin ->>>>>>> Working nested permissions serializer, refactored Asset nested View (to use AssetNestedMixin) class HookViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, viewsets.ModelViewSet): """ - ## External services - Lists the external services endpoints accessible to requesting user -
     GET /api/v2/assets/{asset_uid}/hooks/
     
- > Example > > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/ - ## CRUD - * `asset_uid` - is the unique identifier of a specific asset * `uid` - is the unique identifier of a specific external service - #### Retrieves an external service
     GET /api/v2/assets/{asset_uid}/hooks/{uid}
     
- - > Example > > curl -X GET https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb - #### Add an external service to asset.
     POST /api/v2/assets/{asset_uid}/hooks/
     
- - > Example > > curl -X POST https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/ - - > **Payload to create a new external service** > > { @@ -87,73 +67,51 @@ class HookViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, > }, > "payload_template": {string} > } - where - * `name` and `endpoint` are required * `active` is True by default * `export_type` must be one these values: - 1. `json` (_default_) 2. `xml` - * `email_notification` is a boolean. If true, User will be notified when request to remote server has failed. * `auth_level` must be one these values: - 1. `no_auth` (_default_) 2. `basic_auth` - * `subset_fields` is the list of fields of the form definition. Only these fields should be present in data sent to remote server * `settings`.`custom_headers` is dictionary of `custom header`: `value` - For example: > "settings": { > "customer_headers": { > "Authorization" : "Token 1af538baa9045a84c0e889f672baf83ff24" > } - * `payload_template` is a custom wrapper around `%SUBMISSION%` when sending data to remote server. It can be used only with JSON submission format. - For example: > "payload_template": '{"fields": %SUBMISSION%}' - #### Update an external service.
     PATCH /api/v2/assets/{asset_uid}/hooks/{uid}
     
- - > Example > > curl -X PATCH https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb - - Only specify properties to update in the payload. See above for payload structure - #### Delete an external service.
     DELETE /api/v2/assets/{asset_uid}/hooks/{uid}
     
- - > Example > > curl -X DELETE https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb - #### Retries all failed attempts
     PATCH /api/v2/assets/{asset_uid}/hooks/{hook_uid}/retry/
     
- **This call is asynchronous. Job is sent to Celery to be run in background** - > Example > > curl -X PATCH https://[kpi-url]/api/v2/assets/a9PkXcgVgaDXuwayVeAuY5/hooks/hfgha2nxBdoTVcwohdYNzb/retry/ - It returns all logs `uid`s that are being retried. - ### CURRENT ENDPOINT """ @@ -166,7 +124,11 @@ class HookViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, def get_queryset(self): queryset = self.model.objects.filter(asset__uid=self.asset.uid) - queryset = queryset.select_related("asset__uid") + # Even though we only need 'uid', `select_related('asset__uid')` + # actually pulled in the entire `kpi_asset` table under Django 1.8. In + # Django 1.9, "select_related() prohibits non-relational fields for + # nested relations." + queryset = queryset.select_related('asset') return queryset def perform_create(self, serializer): diff --git a/kpi/utils/viewset_mixin.py b/kpi/utils/viewset_mixin.py deleted file mode 100644 index 4f02fc9327..0000000000 --- a/kpi/utils/viewset_mixin.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -from django.shortcuts import get_object_or_404 - -from kpi.models import Asset - - -class AssetNestedObjectViewsetMixin: - - @property - def asset(self): - if not hasattr(self, '_asset'): - asset = get_object_or_404(Asset, uid=self.asset_uid) - setattr(self, '_asset', asset) - return self._asset - - @property - def asset_uid(self): - if not hasattr(self, '_asset_uid'): - asset_uid = self.get_parents_query_dict().get("asset") - setattr(self, '_asset_uid', asset_uid) - return self._asset_uid diff --git a/kpi/views/v2/hook_signal.py b/kpi/views/v2/hook_signal.py index ef2eab9aa5..5b7d2c6ef0 100644 --- a/kpi/views/v2/hook_signal.py +++ b/kpi/views/v2/hook_signal.py @@ -9,7 +9,7 @@ from kobo.apps.hook.utils import HookUtils from kpi.models import Asset from kpi.utils.log import logging -from kpi.utils.viewset_mixin import AssetNestedObjectViewsetMixin +from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin class HookSignalViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, From d06bfc1f89a6c051bb66f961a05e8d6fbb6af922 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Sat, 18 May 2019 10:28:35 -0400 Subject: [PATCH 059/499] Moved HookSignal Viewset to Hook app --- kpi/views/v1/hook_signal.py | 35 ---------------- kpi/views/v2/hook_signal.py | 80 ------------------------------------- 2 files changed, 115 deletions(-) delete mode 100644 kpi/views/v1/hook_signal.py delete mode 100644 kpi/views/v2/hook_signal.py diff --git a/kpi/views/v1/hook_signal.py b/kpi/views/v1/hook_signal.py deleted file mode 100644 index d490c9fff7..0000000000 --- a/kpi/views/v1/hook_signal.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from kpi.views.v2.hook_signal import HookSignalViewSet as HookSignalViewSetV2 - - -class HookSignalViewSet(HookSignalViewSetV2): - """ - ## This document is for a deprecated version of kpi's API. - - **Please upgrade to latest release `/api/v2/collections/`** - - - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - - pass diff --git a/kpi/views/v2/hook_signal.py b/kpi/views/v2/hook_signal.py deleted file mode 100644 index 5b7d2c6ef0..0000000000 --- a/kpi/views/v2/hook_signal.py +++ /dev/null @@ -1,80 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import - -from django.utils.translation import ugettext_lazy as _ -from rest_framework import status, viewsets -from rest_framework.response import Response -from rest_framework_extensions.mixins import NestedViewSetMixin - -from kobo.apps.hook.utils import HookUtils -from kpi.models import Asset -from kpi.utils.log import logging -from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin - - -class HookSignalViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, - viewsets.ViewSet): - """ - ## - This endpoint is only used to trigger asset's hooks if any. - - Tells the hooks to post an instance to external servers. -
-    POST /assets/{uid}/hook-signal/
-    
- - - > Example - > - > curl -X POST https://[kpi-url]/assets/aSAvYreNzVEkrWg5Gdcvg/hook-signal/ - - - > **Expected payload** - > - > { - > "instance_id": {integer} - > } - - """ - - parent_model = Asset - - def create(self, request, *args, **kwargs): - """ - It's only used to trigger hook services of the Asset (so far). - - :param request: - :return: - """ - # Follow Open Rosa responses by default - response_status_code = status.HTTP_202_ACCEPTED - response = { - "detail": _( - "We got and saved your data, but may not have fully processed it. You should not try to resubmit.") - } - try: - instance_id = request.data.get("instance_id") - instance = self.asset.deployment.get_submission(instance_id) - - # Check if instance really belongs to Asset. - if not (instance and instance.get(self.asset.deployment.INSTANCE_ID_FIELDNAME) == instance_id): - response_status_code = status.HTTP_404_NOT_FOUND - response = { - "detail": _("Resource not found") - } - - elif not HookUtils.call_services(self.asset, instance_id): - response_status_code = status.HTTP_409_CONFLICT - response = { - "detail": _( - "Your data for instance {} has been already submitted.".format(instance_id)) - } - - except Exception as e: - logging.error("HookSignalViewSet.create - {}".format(str(e))) - response = { - "detail": _("An error has occurred when calling the external service. Please retry later.") - } - response_status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - - return Response(response, status=response_status_code) From 4e8f2a7e8aa1dc5d8602979d3f3e27da74b6ab27 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 21 May 2019 16:15:24 -0400 Subject: [PATCH 060/499] Added some documentation for API v2 --- kpi/views/v2/asset_permission.py | 81 +++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 13e608d16e..a254386f79 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -19,7 +19,86 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, DestroyModelMixin, ListModelMixin, viewsets.GenericViewSet): """ - TODO documentation + ## Permissions of an asset + + This endpoint shows assignments on an asset. An assignment implies: + + - a `Permission` object + - a `User` object + + **Roles' permissions:** + + - Owner sees all permissions + - Editors see all permissions + - Viewers see owner's permissions and their permissions + - Anonymous users see only owner's permissions + + + `uid` - is the unique identifier of a specific asset + + **Retrieve assignments** +
+    GET /api/v2/assets/{uid}/permissions/
+    
+ + > Example + > + > curl -X GET https://[kpi]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/ + + + **Assign a permission** +
+    POST /api/v2/assets/{uid}/permissions/
+    
+ + > Example + > + > curl -X POST https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/ \\ + > -H 'Content-Type: application/json' \\ + > -d '' # Payload is sent as the string + + + > _Payload to assign a permission_ + > + > { + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{codename}/", + > } + + > _Payload to assign partial permissions_ + > + > { + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{partial_permission_codename}/", + > "partial_permissions": [ + > { + > "url": "https://[kpi]/api/v2/permissions/{codename}/", + > "filters": [ + > {"_submitted_by": {"$in": ["{username}", "{username}"]}} + > ] + > }, + > ] + > } + + N.B.: + + - Only submissions support partial (`view`) permissions so far. + - Filters use Mongo Query Engine to narrow down results. + - Implied permissions will be also assigned. (e.g. `change_asset` will add `view_asset` too) + + + + **Remove a permission** + TODO - Block owner deletion +
+    DELETE /api/v2/assets/{uid}/permissions/{permission_uid}/
+    
+ + > Example + > + > curl -X DELETE https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/pG6AeSjCwNtpWazQAX76Ap/ + + ### CURRENT ENDPOINT """ From 395690ab376539861309232fb662c127c78dd487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 23 May 2019 14:55:22 -0400 Subject: [PATCH 061/499] Requested changes for PR#2222 --- .../kc_access/shadow_models.py | 5 ++- kpi/utils/redis_helper.py | 38 ++++++++++--------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index 121dac8b7a..122fd867ac 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -440,8 +440,9 @@ class Meta(ShadowModel.Meta): @classmethod def sync(cls, auth_token): try: - kc_auth_token = cls.objects.get(pk=auth_token.pk) - assert kc_auth_user.user_id == auth_token.user_id + # Token use a One-to-One relationship on User. + # Thus, we can retrieve tokens from users' id. + kc_auth_token = cls.objects.get(user_id=auth_token.user_id) except KCToken.DoesNotExist: kc_auth_token = cls(pk=auth_token.pk, user=auth_token.user) diff --git a/kpi/utils/redis_helper.py b/kpi/utils/redis_helper.py index 27afa351af..8615141e66 100644 --- a/kpi/utils/redis_helper.py +++ b/kpi/utils/redis_helper.py @@ -4,6 +4,8 @@ import os import re +from django.core.exceptions import ImproperlyConfigured + class RedisHelper(object): """ @@ -16,24 +18,24 @@ class RedisHelper(object): @staticmethod def config(default=None): """ + Parses `REDIS_SESSION_URL` environment variable to return a dict with + expected attributes for django redis session. + :return: dict """ - try: - redis_connection_url = os.getenv("REDIS_SESSION_URL", default) - match = re.match(r"redis://(:(?P[^@]*)@)?(?P[^:]+):(?P\d+)(/(?P\d+))?", - redis_connection_url) - if not match: - raise Exception() - - redis_connection_dict = { - "host": match.group("host"), - "port": match.group("port"), - "db": match.group("index") or 0, - "password": match.group("password"), - "prefix": os.getenv("REDIS_SESSION_PREFIX", "session"), - "socket_timeout": os.getenv("REDIS_SESSION_SOCKET_TIMEOUT", 1), - } - return redis_connection_dict - except Exception as e: - raise Exception("Could not parse Redis session URL. Please verify 'REDIS_SESSION_URL' value") + redis_connection_url = os.getenv("REDIS_SESSION_URL", default) + match = re.match(r"redis://(:(?P[^@]*)@)?(?P[^:]+):(?P\d+)(/(?P\d+))?", + redis_connection_url) + if not match: + raise ImproperlyConfigured("Could not parse Redis session URL. Please verify 'REDIS_SESSION_URL' value") + + redis_connection_dict = { + "host": match.group("host"), + "port": match.group("port"), + "db": match.group("index") or 0, + "password": match.group("password"), + "prefix": os.getenv("REDIS_SESSION_PREFIX", "session"), + "socket_timeout": os.getenv("REDIS_SESSION_SOCKET_TIMEOUT", 1), + } + return redis_connection_dict From b333c8ae2903e9c110f221ac99e2958841f7ff01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 24 May 2019 15:59:03 -0400 Subject: [PATCH 062/499] Added unittests for new endpoint 'AssetPermission' --- kpi/tests/api/v2/test_api_asset_permission.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 kpi/tests/api/v2/test_api_asset_permission.py diff --git a/kpi/tests/api/v2/test_api_asset_permission.py b/kpi/tests/api/v2/test_api_asset_permission.py new file mode 100644 index 0000000000..b9f3b90d4b --- /dev/null +++ b/kpi/tests/api/v2/test_api_asset_permission.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from rest_framework import status + +from kpi.constants import PERM_VIEW_SUBMISSIONS, \ + PERM_PARTIAL_SUBMISSIONS, PERM_CHANGE_SUBMISSIONS +from kpi.models import Asset +from kpi.models.object_permission import get_anonymous_user +from kpi.tests.base_test_case import BaseTestCase +from kpi.tests.kpi_test_case import KpiTestCase +from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE + + +class BaseApiAssetPermissionTestCase(KpiTestCase): + + fixtures = ["test_data"] + + URL_NAMESPACE = ROUTER_URL_NAMESPACE + + def setUp(self): + self.admin = User.objects.get(username='admin') + self.someuser = User.objects.get(username='someuser') + self.anotheruser = User.objects.get(username='anotheruser') + + self.client.login(username='admin', password='pass') + self.asset = self.create_asset('An asset to be shared') + + self.someuser_detail_url = reverse( + self._get_endpoint('user-detail'), + kwargs={'username': self.someuser.username}) + + self.anotheruser_detail_url = reverse( + self._get_endpoint('user-detail'), + kwargs={'username': self.anotheruser.username}) + + self.view_asset_permission_detail_url = reverse( + self._get_endpoint('permission-detail'), + kwargs={'codename': 'view_asset'}) + + self.change_asset_permission_detail_url = reverse( + self._get_endpoint('permission-detail'), + kwargs={'codename': 'change_asset'}) + + self.asset_permissions_list_url = reverse( + self._get_endpoint('asset-permission-list'), + kwargs={'parent_lookup_asset': self.asset.uid} + ) + + +class ApiAssetPermissionTestCase(BaseApiAssetPermissionTestCase): + + def _logged_user_gives_permission(self, username, permission): + data = { + 'user': getattr(self, '{}_detail_url'.format(username)), + 'permission': getattr(self, '{}_permission_detail_url'.format(permission)) + } + response = self.client.post(self.asset_permissions_list_url, + data, format='json') + return response + + def test_owner_can_give_permissions(self): + # Current user is `self.admin` + response = self._logged_user_gives_permission('someuser', 'view_asset') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_viewers_can_not_give_permissions(self): + self._logged_user_gives_permission('someuser', 'view_asset') + self.client.login(username='someuser', password='someuser') + # Current user is now: `self.someuser` + response = self._logged_user_gives_permission('anotheruser', 'view_asset') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_editors_can_give_permissions(self): + self._logged_user_gives_permission('someuser', 'change_asset') + self.client.login(username='someuser', password='someuser') + # Current user is now: `self.someuser` + response = self._logged_user_gives_permission('anotheruser', 'view_asset') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_anonymous_can_not_give_permissions(self): + self.client.logout() + response = self._logged_user_gives_permission('someuser', 'view_asset') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class ApiAssetPermissionListTestCase(BaseApiAssetPermissionTestCase): + """ + TODO Refactor tests - Redundant codes + """ + fixtures = ["test_data"] + + URL_NAMESPACE = ROUTER_URL_NAMESPACE + + def setUp(self): + super(ApiAssetPermissionListTestCase, self).setUp() + + self.asset.assign_perm(self.someuser, 'change_asset') + self.asset.assign_perm(self.anotheruser, 'view_asset') + + def test_viewers_see_only_their_own_assignments_and_owner_s(self): + + # Checks if can see all permissions + self.client.login(username='anotheruser', password='anotheruser') + permission_list_response = self.client.get(self.asset_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + admin_perms = self.asset.get_perms(self.admin) + anotheruser_perms = self.asset.get_perms(self.anotheruser) + results = permission_list_response.data.get('results') + + # `anotheruser` can only see the owner's permissions `self.admin` and + # `anotheruser`'s permissions. Should not see `someuser`s ones. + expected_perms = [] + for admin_perm in admin_perms: + if admin_perm in Asset.get_assignable_permissions(): + expected_perms.append((self.admin.username, admin_perm)) + for anotheruser_perm in anotheruser_perms: + if anotheruser_perm in Asset.get_assignable_permissions(): + expected_perms.append((self.anotheruser.username, anotheruser_perm)) + + expected_perms = sorted(expected_perms, key=lambda element: (element[0], + element[1])) + obj_perms = [] + for assignment in results: + object_permission = self.url_to_obj(assignment.get('url')) + obj_perms.append((object_permission.user.username, + object_permission.permission.codename)) + + obj_perms = sorted(obj_perms, key=lambda element: (element[0], + element[1])) + + self.assertEqual(expected_perms, obj_perms) + + def test_editors_see_all_assignments(self): + + self.client.login(username='someuser', password='someuser') + permission_list_response = self.client.get(self.asset_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + admin_perms = self.asset.get_perms(self.admin) + someuser_perms = self.asset.get_perms(self.someuser) + anotheruser_perms = self.asset.get_perms(self.anotheruser) + results = permission_list_response.data.get('results') + + # As an editor of the asset. `someuser` should see all. + expected_perms = [] + for admin_perm in admin_perms: + if admin_perm in Asset.get_assignable_permissions(): + expected_perms.append((self.admin.username, admin_perm)) + for someuser_perm in someuser_perms: + if someuser_perm in Asset.get_assignable_permissions(): + expected_perms.append((self.someuser.username, someuser_perm)) + for anotheruser_perm in anotheruser_perms: + if anotheruser_perm in Asset.get_assignable_permissions(): + expected_perms.append((self.anotheruser.username, anotheruser_perm)) + + expected_perms = sorted(expected_perms, key=lambda element: (element[0], + element[1])) + obj_perms = [] + for assignment in results: + object_permission = self.url_to_obj(assignment.get('url')) + obj_perms.append((object_permission.user.username, + object_permission.permission.codename)) + + obj_perms = sorted(obj_perms, key=lambda element: (element[0], + element[1])) + + self.assertEqual(expected_perms, obj_perms) + + def test_anonymous_get_only_owner_s_assignments(self): + + self.client.logout() + self.asset.assign_perm(get_anonymous_user(), 'view_asset') + permission_list_response = self.client.get(self.asset_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + admin_perms = self.asset.get_perms(self.admin) + results = permission_list_response.data.get('results') + + # As an editor of the asset. `someuser` should see all. + expected_perms = [] + for admin_perm in admin_perms: + if admin_perm in Asset.get_assignable_permissions(): + expected_perms.append((self.admin.username, admin_perm)) + + expected_perms = sorted(expected_perms, key=lambda element: (element[0], + element[1])) + obj_perms = [] + for assignment in results: + object_permission = self.url_to_obj(assignment.get('url')) + obj_perms.append((object_permission.user.username, + object_permission.permission.codename)) + + obj_perms = sorted(obj_perms, key=lambda element: (element[0], + element[1])) + self.assertEqual(expected_perms, obj_perms) From 8be078c6b59b2a18704df047bbb45f674f323039 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 19 Jun 2019 14:59:57 -0400 Subject: [PATCH 063/499] Allow asset permissions bulk inserts --- kpi/serializers/v2/asset_permission.py | 20 ++++- kpi/tests/api/v2/test_api_asset_permission.py | 3 - kpi/views/v2/asset_permission.py | 77 ++++++++++++++++--- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 8caf27e1a8..c904d6f606 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -95,7 +95,10 @@ def validate_partial_permissions(self, attrs): """ permission = attrs.get('permission') request = self.context.get('request') - partial_permissions = request.data.get('partial_permissions') + if isinstance(request.data, dict): + partial_permissions = request.data.get('partial_permissions') + elif self.context.get('partial_permissions'): + partial_permissions = self.context.get('partial_permissions') partial_permissions_attr = {} if permission.codename.startswith(PREFIX_PARTIAL_PERMS): @@ -194,3 +197,18 @@ def __get_permission_hyperlink(self, codename): return reverse('permission-detail', args=(codename,), request=self.context.get('request', None)) + + +class AssetBulkInsertPermissionSerializer(AssetPermissionSerializer): + + class Meta: + model = ObjectPermission + fields = ( + 'user', + 'permission', + ) + + def create(self, validated_data): + view = self.context.get('view') + validated_data['asset'] = view.asset + return super(AssetBulkInsertPermissionSerializer, self).create(validated_data) diff --git a/kpi/tests/api/v2/test_api_asset_permission.py b/kpi/tests/api/v2/test_api_asset_permission.py index b9f3b90d4b..c27c000018 100644 --- a/kpi/tests/api/v2/test_api_asset_permission.py +++ b/kpi/tests/api/v2/test_api_asset_permission.py @@ -5,11 +5,8 @@ from django.core.urlresolvers import reverse from rest_framework import status -from kpi.constants import PERM_VIEW_SUBMISSIONS, \ - PERM_PARTIAL_SUBMISSIONS, PERM_CHANGE_SUBMISSIONS from kpi.models import Asset from kpi.models.object_permission import get_anonymous_user -from kpi.tests.base_test_case import BaseTestCase from kpi.tests.kpi_test_case import KpiTestCase from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index a254386f79..aab8715ee9 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import -from rest_framework import viewsets, status +from rest_framework import viewsets, status, renderers +from rest_framework.decorators import list_route from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ DestroyModelMixin, ListModelMixin from rest_framework.response import Response @@ -9,7 +10,8 @@ from kpi.models.object_permission import ObjectPermission from kpi.permissions import AssetNestedObjectPermission -from kpi.serializers.v2.asset_permission import AssetPermissionSerializer +from kpi.serializers.v2.asset_permission import AssetPermissionSerializer, \ + AssetBulkInsertPermissionSerializer from kpi.utils.object_permission_helper import ObjectPermissionHelper from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin @@ -89,7 +91,7 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, **Remove a permission** - TODO - Block owner deletion + TODO - Block owner deletion
     DELETE /api/v2/assets/{uid}/permissions/{permission_uid}/
     
@@ -99,6 +101,28 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, > curl -X DELETE https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/pG6AeSjCwNtpWazQAX76Ap/ + **Assign all permissions at once** + + All permissions will erased (except the owner's) before new assignments +
+    POST /api/v2/assets/{uid}/permissions/bulk/
+    
+ + > Example + > + > curl -X POST https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/bulk/ + + > _Payload to assign all permissions at once_ + > + > [{ + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{codename}/", + > }, + > { + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{codename}/", + > },...] + ### CURRENT ENDPOINT """ @@ -107,6 +131,45 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, serializer_class = AssetPermissionSerializer permission_classes = (AssetNestedObjectPermission,) + @list_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer], + url_path='bulk') + def bulk_assignments(self, request, *args, **kwargs): + """ + Assigns all permissions at once for the same asset. + + :param request: + :return: JSON + """ + + assignments = request.data + + # First delete all assignments before assigning new ones. + # If something fails later, this query should rollback + self.asset.permissions.exclude(user__username=self.asset.owner.username).delete() + + for assignment in assignments: + context_ = dict(self.get_serializer_context()) + if 'partial_permissions' in assignment: + context_.update({'partial_permissions': assignment['partial_permissions']}) + serializer = AssetBulkInsertPermissionSerializer( + data=assignment, + context=context_ + ) + serializer.is_valid(raise_exception=True) + serializer.save() + + # returns asset permissions. Users who can change permissions can + # see all permissions. + return self.list(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + # TODO block owner's permission + object_permission = self.get_object() + user = object_permission.user + codename = object_permission.permission.codename + self.asset.remove_perm(user, codename) + return Response(status=status.HTTP_204_NO_CONTENT) + def get_serializer_context(self): """ Extra context provided to the serializer class. @@ -129,11 +192,3 @@ def list(self, request, *args, **kwargs): def perform_create(self, serializer): serializer.save(asset=self.asset) - - def destroy(self, request, *args, **kwargs): - # TODO block owner's permission - object_permission = self.get_object() - user = object_permission.user - codename = object_permission.permission.codename - self.asset.remove_perm(user, codename) - return Response(status=status.HTTP_204_NO_CONTENT) From 54edb1fa9822e1c7ab1854cc279434c8e6ed0d78 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 19 Jun 2019 15:28:56 -0400 Subject: [PATCH 064/499] Pass asset to serialized on bulk permissions inserts --- kpi/serializers/v2/asset_permission.py | 5 ----- kpi/views/v2/asset_permission.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index c904d6f606..bce11a2329 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -207,8 +207,3 @@ class Meta: 'user', 'permission', ) - - def create(self, validated_data): - view = self.context.get('view') - validated_data['asset'] = view.asset - return super(AssetBulkInsertPermissionSerializer, self).create(validated_data) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index aab8715ee9..549f554055 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -156,7 +156,7 @@ def bulk_assignments(self, request, *args, **kwargs): context=context_ ) serializer.is_valid(raise_exception=True) - serializer.save() + serializer.save(asset=self.asset) # returns asset permissions. Users who can change permissions can # see all permissions. From 8e4f7b9b3f5513faaeb40f001566bb657e0d3f47 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Thu, 20 Jun 2019 16:01:26 -0400 Subject: [PATCH 065/499] Allow partial permissions copy from one asset to another --- kpi/views/v2/asset_permission.py | 47 ++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 549f554055..1054186603 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -1,13 +1,16 @@ # -*- coding: utf-8 -*- -from __future__ import absolute_import +from __future__ import absolute_import, unicode_literals -from rest_framework import viewsets, status, renderers +from django.shortcuts import get_object_or_404 +from rest_framework import exceptions, viewsets, status, renderers from rest_framework.decorators import list_route from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ DestroyModelMixin, ListModelMixin from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin +from kpi.constants import CLONE_ARG_NAME +from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission from kpi.permissions import AssetNestedObjectPermission from kpi.serializers.v2.asset_permission import AssetPermissionSerializer, \ @@ -91,6 +94,7 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, **Remove a permission** + TODO - Block owner deletion
     DELETE /api/v2/assets/{uid}/permissions/{permission_uid}/
@@ -123,6 +127,24 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin,
     >           "permission": "https://[kpi]/api/v2/permissions/{codename}/",
     >        },...]
 
+
+    **Clone permissions from another asset**
+
+    All permissions will erased (except the owner's) before new assignments
+    
+    PATCH /api/v2/assets/{uid}/permissions/clone/
+    
+ + > Example + > + > curl -X PATCH https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/clone/ + + > _Payload to clone permissions from another asset_ + > + > { + > "clone_from": "{source_asset_uid}" + > } + ### CURRENT ENDPOINT """ @@ -162,6 +184,27 @@ def bulk_assignments(self, request, *args, **kwargs): # see all permissions. return self.list(request, *args, **kwargs) + @list_route(methods=['PATCH'], renderer_classes=[renderers.JSONRenderer]) + def clone(self, request, *args, **kwargs): + + source_asset_uid = self.request.data[CLONE_ARG_NAME] + source_asset = get_object_or_404(Asset, uid=source_asset_uid) + user = request.user + + if user.has_perm('share_asset', self.asset) and \ + user.has_perm('view_asset', source_asset): + if not self.asset.copy_permissions_from(source_asset): + http_status = status.HTTP_400_BAD_REQUEST + response = {"detail": "Source and destination objects don't " + "seem to have the same type"} + return Response(response, status=http_status) + else: + raise exceptions.PermissionDenied() + + # returns asset permissions. Users who can change permissions can + # see all permissions. + return self.list(request, *args, **kwargs) + def destroy(self, request, *args, **kwargs): # TODO block owner's permission object_permission = self.get_object() From 7119c2fffe2a0afffe45c654b6b7e6051a26bbe8 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Fri, 21 Jun 2019 10:10:53 -0400 Subject: [PATCH 066/499] Block owner's permissions deletion --- kpi/views/v2/asset_permission.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 1054186603..ffdff88aa3 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -95,7 +95,6 @@ class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, **Remove a permission** - TODO - Block owner deletion
     DELETE /api/v2/assets/{uid}/permissions/{permission_uid}/
     
@@ -206,9 +205,13 @@ def clone(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): - # TODO block owner's permission object_permission = self.get_object() user = object_permission.user + if user.pk == self.asset.owner_id: + return Response({ + 'detail': "Owner's permissions can not be deleted" + }, status=status.HTTP_409_CONFLICT) + codename = object_permission.permission.codename self.asset.remove_perm(user, codename) return Response(status=status.HTTP_204_NO_CONTENT) From 55d1a79d9e559d57f4820877c7384e0e35a0011b Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 25 Jun 2019 09:58:52 -0400 Subject: [PATCH 067/499] Added nested permissions to collections --- kpi/serializers/v2/collection_permission.py | 92 ++++++++ kpi/views/v2/asset_permission.py | 9 +- kpi/views/v2/collection_permission.py | 224 ++++++++++++++++++++ 3 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 kpi/serializers/v2/collection_permission.py create mode 100644 kpi/views/v2/collection_permission.py diff --git a/kpi/serializers/v2/collection_permission.py b/kpi/serializers/v2/collection_permission.py new file mode 100644 index 0000000000..f4402ef100 --- /dev/null +++ b/kpi/serializers/v2/collection_permission.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.contrib.auth.models import Permission, User +from rest_framework import serializers +from rest_framework.reverse import reverse + +from kpi.fields.relative_prefix_hyperlinked_related import \ + RelativePrefixHyperlinkedRelatedField +from kpi.models.collection import Collection +from kpi.models.object_permission import ObjectPermission + + +class CollectionPermissionSerializer(serializers.ModelSerializer): + + url = serializers.SerializerMethodField() + user = RelativePrefixHyperlinkedRelatedField( + view_name='user-detail', + lookup_field='username', + queryset=User.objects.all(), + style={'base_template': 'input.html'} # Render as a simple text box + ) + permission = RelativePrefixHyperlinkedRelatedField( + view_name='permission-detail', + lookup_field='codename', + queryset=Permission.objects.all(), + style={'base_template': 'input.html'} # Render as a simple text box + ) + + class Meta: + model = ObjectPermission + fields = ( + 'url', + 'user', + 'permission' + ) + + read_only_fields = ('uid', ) + + def create(self, validated_data): + user = validated_data['user'] + collection = validated_data['collection'] + permission = validated_data['permission'] + return collection.assign_perm(user, permission.codename) + + def get_url(self, object_permission): + collection_uid = self.context.get('collection_uid') + return reverse('collection-permission-detail', + args=(collection_uid, object_permission.uid), + request=self.context.get('request', None)) + + def validate_permission(self, permission): + """ + Checks if permission can be assigned on asset. + """ + if not self._validate_permission(permission.codename): + raise serializers.ValidationError( + '{} cannot be assigned explicitly to Asset objects.'.format( + permission.codename)) + return permission + + def _validate_permission(self, codename, suffix=None): + """ + Validates if `codename` can be assigned on `Collection`s. + Search can be restricted to assignable codenames which end with `prefix` + + :param codename: str. See `Collection.ASSIGNABLE_PERMISSIONS + :param suffix: str. + :return: bool. + """ + return (codename in Collection.get_assignable_permissions(with_partial=True) + and (suffix is None or codename.endswith(suffix))) + + def __get_permission_hyperlink(self, codename): + """ + Builds permission hyperlink representation. + :param codename: str + :return: str. url + """ + return reverse('permission-detail', + args=(codename,), + request=self.context.get('request', None)) + + +class CollectionBulkInsertPermissionSerializer(CollectionPermissionSerializer): + + class Meta: + model = ObjectPermission + fields = ( + 'user', + 'permission', + ) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index ffdff88aa3..066e2e8b60 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin -from kpi.constants import CLONE_ARG_NAME +from kpi.constants import CLONE_ARG_NAME, PERM_VIEW_ASSET, PERM_SHARE_ASSET from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission from kpi.permissions import AssetNestedObjectPermission @@ -190,8 +190,8 @@ def clone(self, request, *args, **kwargs): source_asset = get_object_or_404(Asset, uid=source_asset_uid) user = request.user - if user.has_perm('share_asset', self.asset) and \ - user.has_perm('view_asset', source_asset): + if user.has_perm(PERM_SHARE_ASSET, self.asset) and \ + user.has_perm(PERM_VIEW_ASSET, source_asset): if not self.asset.copy_permissions_from(source_asset): http_status = status.HTTP_400_BAD_REQUEST response = {"detail": "Source and destination objects don't " @@ -233,8 +233,5 @@ def get_queryset(self): return ObjectPermissionHelper.get_assignments_queryset(self.asset, self.request.user) - def list(self, request, *args, **kwargs): - return super(AssetPermissionViewSet, self).list(request, *args, **kwargs) - def perform_create(self, serializer): serializer.save(asset=self.asset) diff --git a/kpi/views/v2/collection_permission.py b/kpi/views/v2/collection_permission.py new file mode 100644 index 0000000000..aa9fc54f1e --- /dev/null +++ b/kpi/views/v2/collection_permission.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from django.shortcuts import get_object_or_404 +from rest_framework import exceptions, viewsets, status, renderers +from rest_framework.decorators import list_route +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ + DestroyModelMixin, ListModelMixin +from rest_framework.response import Response +from rest_framework_extensions.mixins import NestedViewSetMixin + +from kpi.constants import CLONE_ARG_NAME, PERM_SHARE_COLLECTION, \ + PERM_VIEW_COLLECTION +from kpi.models.collection import Collection +from kpi.models.object_permission import ObjectPermission +from kpi.permissions import CollectionNestedObjectPermission +from kpi.serializers.v2.collection_permission import CollectionPermissionSerializer, \ + CollectionBulkInsertPermissionSerializer +from kpi.utils.object_permission_helper import ObjectPermissionHelper +from kpi.utils.viewset_mixins import CollectionNestedObjectViewsetMixin + + +class CollectionPermissionViewSet(CollectionNestedObjectViewsetMixin, NestedViewSetMixin, + CreateModelMixin, RetrieveModelMixin, + DestroyModelMixin, ListModelMixin, + viewsets.GenericViewSet): + + # TODO Refactor AssetPermissionViewSet & CollectionPermissionViewSet tox + # use same core. + + """ + ## Permissions of an collection + + This endpoint shows assignments on an collection. An assignment implies: + + - a `Permission` object + - a `User` object + + **Roles' permissions:** + + - Owner sees all permissions + - Editors see all permissions + - Viewers see owner's permissions and their permissions + - Anonymous users see only owner's permissions + + + `uid` - is the unique identifier of a specific collection + + **Retrieve assignments** +
+    GET /api/v2/collections/{uid}/permissions/
+    
+ + > Example + > + > curl -X GET https://[kpi]/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/ + + + **Assign a permission** +
+    POST /api/v2/collections/{uid}/permissions/
+    
+ + > Example + > + > curl -X POST https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/ \\ + > -H 'Content-Type: application/json' \\ + > -d '' # Payload is sent as the string + + + > _Payload to assign a permission_ + > + > { + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{codename}/", + > } + + N.B.: + + - Filters use Mongo Query Engine to narrow down results. + - Implied permissions will be also assigned. (e.g. `change_collection` will add `view_collection` too) + + + + **Remove a permission** + +
+    DELETE /api/v2/collections/{uid}/permissions/{permission_uid}/
+    
+ + > Example + > + > curl -X DELETE https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/pG6AeSjCwNtpWazQAX76Ap/ + + + **Assign all permissions at once** + + All permissions will erased (except the owner's) before new assignments +
+    POST /api/v2/collections/{uid}/permissions/bulk/
+    
+ + > Example + > + > curl -X POST https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/bulk/ + + > _Payload to assign all permissions at once_ + > + > [{ + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{codename}/", + > }, + > { + > "user": "https://[kpi]/api/v2/users/{username}/", + > "permission": "https://[kpi]/api/v2/permissions/{codename}/", + > },...] + + + **Clone permissions from another collection** + + All permissions will erased (except the owner's) before new assignments +
+    PATCH /api/v2/collections/{uid}/permissions/clone/
+    
+ + > Example + > + > curl -X PATCH https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/clone/ + + > _Payload to clone permissions from another collection_ + > + > { + > "clone_from": "{source_collection_uid}" + > } + + ### CURRENT ENDPOINT + """ + + model = ObjectPermission + lookup_field = "uid" + serializer_class = CollectionPermissionSerializer + permission_classes = (CollectionNestedObjectPermission,) + + @list_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer], + url_path='bulk') + def bulk_assignments(self, request, *args, **kwargs): + """ + Assigns all permissions at once for the same collection. + + :param request: + :return: JSON + """ + + assignments = request.data + + # First delete all assignments before assigning new ones. + # If something fails later, this query should rollback + self.collection.permissions.exclude(user__username=self.collection.owner.username).delete() + + for assignment in assignments: + context_ = dict(self.get_serializer_context()) + serializer = CollectionBulkInsertPermissionSerializer( + data=assignment, + context=context_ + ) + serializer.is_valid(raise_exception=True) + serializer.save(collection=self.collection) + + # returns collection permissions. Users who can change permissions can + # see all permissions. + return self.list(request, *args, **kwargs) + + @list_route(methods=['PATCH'], renderer_classes=[renderers.JSONRenderer]) + def clone(self, request, *args, **kwargs): + + source_collection_uid = self.request.data[CLONE_ARG_NAME] + source_collection = get_object_or_404(Collection, uid=source_collection_uid) + user = request.user + + if user.has_perm(PERM_SHARE_COLLECTION, self.collection) and \ + user.has_perm(PERM_VIEW_COLLECTION, source_collection): + if not self.collection.copy_permissions_from(source_collection): + http_status = status.HTTP_400_BAD_REQUEST + response = {"detail": "Source and destination objects don't " + "seem to have the same type"} + return Response(response, status=http_status) + else: + raise exceptions.PermissionDenied() + + # returns collection permissions. Users who can change permissions can + # see all permissions. + return self.list(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + object_permission = self.get_object() + user = object_permission.user + if user.pk == self.collection.owner_id: + return Response({ + 'detail': "Owner's permissions can not be deleted" + }, status=status.HTTP_409_CONFLICT) + + codename = object_permission.permission.codename + self.collection.remove_perm(user, codename) + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_serializer_context(self): + """ + Extra context provided to the serializer class. + Inject collection_uid to avoid extra queries to DB inside the serializer. + @TODO Check if there is a better way to do it? + """ + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self, + 'collection_uid': self.collection.uid + } + + def get_queryset(self): + return ObjectPermissionHelper.get_assignments_queryset(self.collection, + self.request.user) + + def perform_create(self, serializer): + serializer.save(collection=self.collection) From e3bf657b5b424a7bc8ccafa1db2d552d3e6fe554 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 25 Jun 2019 10:53:04 -0400 Subject: [PATCH 068/499] Bug fix - Permissions failed when accessing nested permissions viewsets --- kpi/tests/api/v2/test_api_asset_permission.py | 23 ++- .../api/v2/test_api_collection_permission.py | 194 ++++++++++++++++++ 2 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 kpi/tests/api/v2/test_api_collection_permission.py diff --git a/kpi/tests/api/v2/test_api_asset_permission.py b/kpi/tests/api/v2/test_api_asset_permission.py index c27c000018..0c3d3554d2 100644 --- a/kpi/tests/api/v2/test_api_asset_permission.py +++ b/kpi/tests/api/v2/test_api_asset_permission.py @@ -5,6 +5,7 @@ from django.core.urlresolvers import reverse from rest_framework import status +from kpi.constants import PERM_VIEW_ASSET, PERM_CHANGE_ASSET from kpi.models import Asset from kpi.models.object_permission import get_anonymous_user from kpi.tests.kpi_test_case import KpiTestCase @@ -35,11 +36,11 @@ def setUp(self): self.view_asset_permission_detail_url = reverse( self._get_endpoint('permission-detail'), - kwargs={'codename': 'view_asset'}) + kwargs={'codename': PERM_VIEW_ASSET}) self.change_asset_permission_detail_url = reverse( self._get_endpoint('permission-detail'), - kwargs={'codename': 'change_asset'}) + kwargs={'codename': PERM_CHANGE_ASSET}) self.asset_permissions_list_url = reverse( self._get_endpoint('asset-permission-list'), @@ -60,26 +61,26 @@ def _logged_user_gives_permission(self, username, permission): def test_owner_can_give_permissions(self): # Current user is `self.admin` - response = self._logged_user_gives_permission('someuser', 'view_asset') + response = self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) self.assertEqual(response.status_code, status.HTTP_201_CREATED) def test_viewers_can_not_give_permissions(self): - self._logged_user_gives_permission('someuser', 'view_asset') + self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) self.client.login(username='someuser', password='someuser') # Current user is now: `self.someuser` - response = self._logged_user_gives_permission('anotheruser', 'view_asset') + response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_ASSET) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_editors_can_give_permissions(self): - self._logged_user_gives_permission('someuser', 'change_asset') + self._logged_user_gives_permission('someuser', PERM_CHANGE_ASSET) self.client.login(username='someuser', password='someuser') # Current user is now: `self.someuser` - response = self._logged_user_gives_permission('anotheruser', 'view_asset') + response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_ASSET) self.assertEqual(response.status_code, status.HTTP_201_CREATED) def test_anonymous_can_not_give_permissions(self): self.client.logout() - response = self._logged_user_gives_permission('someuser', 'view_asset') + response = self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -94,8 +95,8 @@ class ApiAssetPermissionListTestCase(BaseApiAssetPermissionTestCase): def setUp(self): super(ApiAssetPermissionListTestCase, self).setUp() - self.asset.assign_perm(self.someuser, 'change_asset') - self.asset.assign_perm(self.anotheruser, 'view_asset') + self.asset.assign_perm(self.someuser, PERM_CHANGE_ASSET) + self.asset.assign_perm(self.anotheruser, PERM_VIEW_ASSET) def test_viewers_see_only_their_own_assignments_and_owner_s(self): @@ -170,7 +171,7 @@ def test_editors_see_all_assignments(self): def test_anonymous_get_only_owner_s_assignments(self): self.client.logout() - self.asset.assign_perm(get_anonymous_user(), 'view_asset') + self.asset.assign_perm(get_anonymous_user(), PERM_VIEW_ASSET) permission_list_response = self.client.get(self.asset_permissions_list_url, format='json') self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) diff --git a/kpi/tests/api/v2/test_api_collection_permission.py b/kpi/tests/api/v2/test_api_collection_permission.py new file mode 100644 index 0000000000..7170f8eaf1 --- /dev/null +++ b/kpi/tests/api/v2/test_api_collection_permission.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from rest_framework import status + +from kpi.constants import PERM_VIEW_COLLECTION, PERM_CHANGE_COLLECTION +from kpi.models import Collection +from kpi.models.object_permission import get_anonymous_user +from kpi.tests.kpi_test_case import KpiTestCase +from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE + + +class BaseApiCollectionPermissionTestCase(KpiTestCase): + + fixtures = ["test_data"] + + URL_NAMESPACE = ROUTER_URL_NAMESPACE + + def setUp(self): + self.admin = User.objects.get(username='admin') + self.someuser = User.objects.get(username='someuser') + self.anotheruser = User.objects.get(username='anotheruser') + + self.client.login(username='admin', password='pass') + self.collection = self.create_collection('A collection to be shared') + + self.someuser_detail_url = reverse( + self._get_endpoint('user-detail'), + kwargs={'username': self.someuser.username}) + + self.anotheruser_detail_url = reverse( + self._get_endpoint('user-detail'), + kwargs={'username': self.anotheruser.username}) + + self.view_collection_permission_detail_url = reverse( + self._get_endpoint('permission-detail'), + kwargs={'codename': PERM_VIEW_COLLECTION}) + + self.change_collection_permission_detail_url = reverse( + self._get_endpoint('permission-detail'), + kwargs={'codename': PERM_CHANGE_COLLECTION}) + + self.collection_permissions_list_url = reverse( + self._get_endpoint('collection-permission-list'), + kwargs={'parent_lookup_collection': self.collection.uid} + ) + + +class ApiCollectionPermissionTestCase(BaseApiCollectionPermissionTestCase): + + def _logged_user_gives_permission(self, username, permission): + data = { + 'user': getattr(self, '{}_detail_url'.format(username)), + 'permission': getattr(self, '{}_permission_detail_url'.format(permission)) + } + response = self.client.post(self.collection_permissions_list_url, + data, format='json') + return response + + def test_owner_can_give_permissions(self): + # Current user is `self.admin` + response = self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_viewers_can_not_give_permissions(self): + self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) + self.client.login(username='someuser', password='someuser') + # Current user is now: `self.someuser` + response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_COLLECTION) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_editors_can_give_permissions(self): + self._logged_user_gives_permission('someuser', PERM_CHANGE_COLLECTION) + self.client.login(username='someuser', password='someuser') + # Current user is now: `self.someuser` + response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_COLLECTION) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_anonymous_can_not_give_permissions(self): + self.client.logout() + response = self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class ApiCollectionPermissionListTestCase(BaseApiCollectionPermissionTestCase): + fixtures = ["test_data"] + + URL_NAMESPACE = ROUTER_URL_NAMESPACE + + def setUp(self): + super(ApiCollectionPermissionListTestCase, self).setUp() + + self.collection.assign_perm(self.someuser, PERM_CHANGE_COLLECTION) + self.collection.assign_perm(self.anotheruser, PERM_VIEW_COLLECTION) + + def test_viewers_see_only_their_own_assignments_and_owner_s(self): + + # Checks if can see all permissions + self.client.login(username='anotheruser', password='anotheruser') + permission_list_response = self.client.get(self.collection_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + admin_perms = self.collection.get_perms(self.admin) + anotheruser_perms = self.collection.get_perms(self.anotheruser) + results = permission_list_response.data.get('results') + + # `anotheruser` can only see the owner's permissions `self.admin` and + # `anotheruser`'s permissions. Should not see `someuser`s ones. + expected_perms = [] + for admin_perm in admin_perms: + if admin_perm in Collection.get_assignable_permissions(): + expected_perms.append((self.admin.username, admin_perm)) + for anotheruser_perm in anotheruser_perms: + if anotheruser_perm in Collection.get_assignable_permissions(): + expected_perms.append((self.anotheruser.username, anotheruser_perm)) + + expected_perms = sorted(expected_perms, key=lambda element: (element[0], + element[1])) + obj_perms = [] + for assignment in results: + object_permission = self.url_to_obj(assignment.get('url')) + obj_perms.append((object_permission.user.username, + object_permission.permission.codename)) + + obj_perms = sorted(obj_perms, key=lambda element: (element[0], + element[1])) + + self.assertEqual(expected_perms, obj_perms) + + def test_editors_see_all_assignments(self): + + self.client.login(username='someuser', password='someuser') + permission_list_response = self.client.get(self.collection_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + admin_perms = self.collection.get_perms(self.admin) + someuser_perms = self.collection.get_perms(self.someuser) + anotheruser_perms = self.collection.get_perms(self.anotheruser) + results = permission_list_response.data.get('results') + + # As an editor of the collection. `someuser` should see all. + expected_perms = [] + for admin_perm in admin_perms: + if admin_perm in Collection.get_assignable_permissions(): + expected_perms.append((self.admin.username, admin_perm)) + for someuser_perm in someuser_perms: + if someuser_perm in Collection.get_assignable_permissions(): + expected_perms.append((self.someuser.username, someuser_perm)) + for anotheruser_perm in anotheruser_perms: + if anotheruser_perm in Collection.get_assignable_permissions(): + expected_perms.append((self.anotheruser.username, anotheruser_perm)) + + expected_perms = sorted(expected_perms, key=lambda element: (element[0], + element[1])) + obj_perms = [] + for assignment in results: + object_permission = self.url_to_obj(assignment.get('url')) + obj_perms.append((object_permission.user.username, + object_permission.permission.codename)) + + obj_perms = sorted(obj_perms, key=lambda element: (element[0], + element[1])) + + self.assertEqual(expected_perms, obj_perms) + + def test_anonymous_get_only_owner_s_assignments(self): + + self.client.logout() + self.collection.assign_perm(get_anonymous_user(), PERM_VIEW_COLLECTION) + permission_list_response = self.client.get(self.collection_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + admin_perms = self.collection.get_perms(self.admin) + results = permission_list_response.data.get('results') + + # As an editor of the collection. `someuser` should see all. + expected_perms = [] + for admin_perm in admin_perms: + if admin_perm in Collection.get_assignable_permissions(): + expected_perms.append((self.admin.username, admin_perm)) + + expected_perms = sorted(expected_perms, key=lambda element: (element[0], + element[1])) + obj_perms = [] + for assignment in results: + object_permission = self.url_to_obj(assignment.get('url')) + obj_perms.append((object_permission.user.username, + object_permission.permission.codename)) + + obj_perms = sorted(obj_perms, key=lambda element: (element[0], + element[1])) + self.assertEqual(expected_perms, obj_perms) From 0cb4ab06f1e0fe503e3c420ef2a383a1fd985fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 11 Jul 2019 10:39:03 -0400 Subject: [PATCH 069/499] Applied requested changes (typos, better readability python code) as per discussed with jnm --- kobo/apps/reports/views.py | 15 --------------- kpi/management/commands/sync_kobocat_xforms.py | 4 ++-- kpi/models/asset.py | 17 ++++++++++------- kpi/serializers/v2/asset_permission.py | 2 +- kpi/tests/api/v2/test_api_asset_permission.py | 5 ++++- kpi/tests/api/v2/test_api_assets.py | 2 +- kpi/views/v2/asset_permission.py | 18 +++++++++--------- kpi/views/v2/collection_permission.py | 18 ++++++++---------- 8 files changed, 35 insertions(+), 46 deletions(-) diff --git a/kobo/apps/reports/views.py b/kobo/apps/reports/views.py index ec5c606277..0b8237047b 100644 --- a/kobo/apps/reports/views.py +++ b/kobo/apps/reports/views.py @@ -9,12 +9,8 @@ ) from kpi.models import Asset from kpi.models.object_permission import get_objects_for_user, get_anonymous_user -<<<<<<< HEAD from .serializers import ReportsListSerializer, ReportsDetailSerializer from kpi.constants import PERM_VIEW_SUBMISSIONS, PERM_PARTIAL_SUBMISSIONS -======= -from kpi.constants import PERM_VIEW_SUBMISSIONS ->>>>>>> Refactored permissions variables, use variables from 'constant.py' instead of hardcoded string class ReportsViewSet(mixins.ListModelMixin, @@ -50,7 +46,6 @@ def get_object(self): def get_queryset(self): -<<<<<<< HEAD queryset = Asset.objects.filter(asset_type=ASSET_TYPE_SURVEY) if self.action == 'retrieve': # `get_object()` will do the checking; no need to manipulate the @@ -84,13 +79,3 @@ def get_queryset(self): ) & Asset.objects.deployed() return deployed_assets -======= - - # Retrieve all deployed assets first. - deployed_assets = Asset.objects.filter(asset_versions__deployed=True).distinct() - # Then retrieve all assets user is allowed to view (user must have `PERM_VIEW_SUBMISSIONS` on Asset objects) - user_assets = get_objects_for_user(self.request.user, PERM_VIEW_SUBMISSIONS, deployed_assets) - publicly_shared_assets = get_objects_for_user(get_anonymous_user(), PERM_VIEW_SUBMISSIONS, deployed_assets) - - return user_assets | publicly_shared_assets ->>>>>>> Refactored permissions variables, use variables from 'constant.py' instead of hardcoded string diff --git a/kpi/management/commands/sync_kobocat_xforms.py b/kpi/management/commands/sync_kobocat_xforms.py index 09838f1d8d..28d62f3cf3 100644 --- a/kpi/management/commands/sync_kobocat_xforms.py +++ b/kpi/management/commands/sync_kobocat_xforms.py @@ -411,8 +411,8 @@ def _sync_permissions(asset, xform): # This user's KPI access came only from this script, and now all KC # permissions have been removed. Purge all KPI grant permissions, # even the non-mapped ones, in order to clean up prerequisite - # permissions (e.g. `PERM_VIEW_ASSET` is a prerequisite of - # `PERM_VIEW_SUBMISSIONS`) + # permissions (e.g. 'view_asset' is a prerequisite of + # 'view_submissions') ObjectPermission.objects.filter( user_id=user, deny=False, diff --git a/kpi/models/asset.py b/kpi/models/asset.py index c0ca9958a8..2e0c3135a4 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -825,6 +825,10 @@ def get_partial_perms( ``` ['view_submissions',] ``` +<<<<<<< HEAD +======= + +>>>>>>> Applied requested changes (typos, better readability python code) as per discussed with jnm `get_partial_perms(user1_obj.id, with_filters=True)` would return ``` { @@ -848,16 +852,15 @@ def get_partial_perms( def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): """ - Returns the list of filters for a specfic permission `perm` - and this specific asset. This - :param user_obj: auth.User + Returns the list of filters for a specific permission `perm` + and this specific asset. + :param user_id: :param perm: see `constants.*_SUBMISSIONS` :return: """ - if not (perm.endswith(SUFFIX_SUBMISSIONS_PERMS) and - not perm == PERM_PARTIAL_SUBMISSIONS): - raise BadPermissionsException("Only global permissions for " - "submissions are supported.") + if not perm.endswith(SUFFIX_SUBMISSIONS_PERMS) or perm == PERM_PARTIAL_SUBMISSIONS: + raise BadPermissionsException(_('Only partial permissions for ' + 'submissions are supported')) perms = self.get_partial_perms(user_id, with_filters=True) if perms: diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index bce11a2329..41ee511952 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -58,7 +58,7 @@ def get_partial_permissions(self, object_permission): # fallback to context. (e.g. AssetViewSet) asset = getattr(view, 'asset', self.context.get('asset')) partial_perms = asset.get_partial_perms( - object_permission.user_id, True) + object_permission.user_id, with_filters=True) hyperlinked_partial_perms = [] for perm_codename, filters in partial_perms.items(): diff --git a/kpi/tests/api/v2/test_api_asset_permission.py b/kpi/tests/api/v2/test_api_asset_permission.py index 0c3d3554d2..f7fcc948aa 100644 --- a/kpi/tests/api/v2/test_api_asset_permission.py +++ b/kpi/tests/api/v2/test_api_asset_permission.py @@ -51,6 +51,9 @@ def setUp(self): class ApiAssetPermissionTestCase(BaseApiAssetPermissionTestCase): def _logged_user_gives_permission(self, username, permission): + """ + Uses the API to grant `permission` to `username` + """ data = { 'user': getattr(self, '{}_detail_url'.format(username)), 'permission': getattr(self, '{}_permission_detail_url'.format(permission)) @@ -178,7 +181,7 @@ def test_anonymous_get_only_owner_s_assignments(self): admin_perms = self.asset.get_perms(self.admin) results = permission_list_response.data.get('results') - # As an editor of the asset. `someuser` should see all. + # Get admin permissions. expected_perms = [] for admin_perm in admin_perms: if admin_perm in Asset.get_assignable_permissions(): diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index af298aa2ac..6f9641ed26 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -207,7 +207,7 @@ def test_asset_version_content_hash(self): self.assertEqual(resp2.data['content_hash'], asset.latest_version.content_hash) - def test_partial_access_to_version(self): + def test_restricted_access_to_version(self): self.client.logout() self.client.login(username='anotheruser', password='anotheruser') resp = self.client.get(self.version_list_url, format='json') diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 066e2e8b60..0e9b78c539 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals from django.shortcuts import get_object_or_404 +from django.utils.translation import ugettext as _ from rest_framework import exceptions, viewsets, status, renderers from rest_framework.decorators import list_route from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ @@ -194,8 +195,8 @@ def clone(self, request, *args, **kwargs): user.has_perm(PERM_VIEW_ASSET, source_asset): if not self.asset.copy_permissions_from(source_asset): http_status = status.HTTP_400_BAD_REQUEST - response = {"detail": "Source and destination objects don't " - "seem to have the same type"} + response = {'detail': _("Source and destination objects don't " + "seem to have the same type")} return Response(response, status=http_status) else: raise exceptions.PermissionDenied() @@ -209,7 +210,7 @@ def destroy(self, request, *args, **kwargs): user = object_permission.user if user.pk == self.asset.owner_id: return Response({ - 'detail': "Owner's permissions can not be deleted" + 'detail': _("Owner's permissions cannot be deleted") }, status=status.HTTP_409_CONFLICT) codename = object_permission.permission.codename @@ -220,14 +221,13 @@ def get_serializer_context(self): """ Extra context provided to the serializer class. Inject asset_uid to avoid extra queries to DB inside the serializer. - @TODO Check if there is a better way to do it? """ - return { - 'request': self.request, - 'format': self.format_kwarg, - 'view': self, + + context_ = super(AssetPermissionViewSet, self).get_serializer_context() + context_.update({ 'asset_uid': self.asset.uid - } + }) + return context_ def get_queryset(self): return ObjectPermissionHelper.get_assignments_queryset(self.asset, diff --git a/kpi/views/v2/collection_permission.py b/kpi/views/v2/collection_permission.py index aa9fc54f1e..00400f0906 100644 --- a/kpi/views/v2/collection_permission.py +++ b/kpi/views/v2/collection_permission.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals from django.shortcuts import get_object_or_404 +from django.utils.translation import ugettext as _ from rest_framework import exceptions, viewsets, status, renderers from rest_framework.decorators import list_route from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ @@ -77,7 +78,6 @@ class CollectionPermissionViewSet(CollectionNestedObjectViewsetMixin, NestedView N.B.: - - Filters use Mongo Query Engine to narrow down results. - Implied permissions will be also assigned. (e.g. `change_collection` will add `view_collection` too) @@ -181,8 +181,8 @@ def clone(self, request, *args, **kwargs): user.has_perm(PERM_VIEW_COLLECTION, source_collection): if not self.collection.copy_permissions_from(source_collection): http_status = status.HTTP_400_BAD_REQUEST - response = {"detail": "Source and destination objects don't " - "seem to have the same type"} + response = {'detail': _("Source and destination objects don't " + "seem to have the same type")} return Response(response, status=http_status) else: raise exceptions.PermissionDenied() @@ -196,7 +196,7 @@ def destroy(self, request, *args, **kwargs): user = object_permission.user if user.pk == self.collection.owner_id: return Response({ - 'detail': "Owner's permissions can not be deleted" + 'detail': _("Owner's permissions cannot be deleted") }, status=status.HTTP_409_CONFLICT) codename = object_permission.permission.codename @@ -207,14 +207,12 @@ def get_serializer_context(self): """ Extra context provided to the serializer class. Inject collection_uid to avoid extra queries to DB inside the serializer. - @TODO Check if there is a better way to do it? """ - return { - 'request': self.request, - 'format': self.format_kwarg, - 'view': self, + context_ = super(CollectionPermissionViewSet, self).get_serializer_context() + context_.update({ 'collection_uid': self.collection.uid - } + }) + return context_ def get_queryset(self): return ObjectPermissionHelper.get_assignments_queryset(self.collection, From 8a3c54edb8b8bfcfac9bde84bc2a50cd29828bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 11 Jul 2019 11:58:57 -0400 Subject: [PATCH 070/499] Python code optimization for 'AssetPermissionSerializer.validate_partial_permissions()' - use of defaultdict, smaller try/except block --- kpi/serializers/v2/asset_permission.py | 47 +++++++++++++------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 41ee511952..5c1d0857fd 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import + +from collections import defaultdict from urlparse import urlparse from django.contrib.auth.models import Permission, User -from django.core.urlresolvers import resolve +from django.core.urlresolvers import resolve, Resolver404 +from django.utils.translation import ugettext as _ from rest_framework import serializers from rest_framework.reverse import reverse @@ -99,46 +102,44 @@ def validate_partial_permissions(self, attrs): partial_permissions = request.data.get('partial_permissions') elif self.context.get('partial_permissions'): partial_permissions = self.context.get('partial_permissions') - partial_permissions_attr = {} + partial_permissions_attr = defaultdict(list) if permission.codename.startswith(PREFIX_PARTIAL_PERMS): if partial_permissions: is_valid = True - try: - for partial_permission, filter_ in \ - self.__get_partial_permissions_generator(partial_permissions): + for partial_permission, filter_ in \ + self.__get_partial_permissions_generator(partial_permissions): + try: parse_result = urlparse(partial_permission.get('url')) resolver = resolve(parse_result.path) codename = resolver.kwargs.get('codename') - # Permission must valid and must be assignable. - # Ensure `filter_` is a `dict`. - # No need to validate Mongo syntax, query will fail - # if syntax is not correct. - if (self._validate_permission(codename, - SUFFIX_SUBMISSIONS_PERMS) - and isinstance(filter_, dict)): - - if codename not in partial_permissions_attr: - partial_permissions_attr[codename] = [] - - partial_permissions_attr[codename].append(filter_) - continue - + except (AttributeError, Resolver404): is_valid = False + break # No need to go further + + # Permission must valid and must be assignable. + # Ensure `filter_` is a `dict`. + # No need to validate Mongo syntax, query will fail + # if syntax is not correct. + if (self._validate_permission(codename, + SUFFIX_SUBMISSIONS_PERMS) + and isinstance(filter_, dict)): + partial_permissions_attr[codename].append(filter_) + continue - except (AttributeError, ValueError): is_valid = False + break # No need to go further if not is_valid: - raise serializers.ValidationError('Invalid partial permissions') + raise serializers.ValidationError(_('Invalid partial permissions')) # Everything went well. Add it to `attrs` attrs.update({'partial_permissions': partial_permissions_attr}) else: raise serializers.ValidationError( - "Can not assign '{}' permission. Partial permissions " - "are missing.".format(permission.codename)) + _("Can not assign '{}' permission. Partial permissions " + "are missing.").format(permission.codename)) return attrs From dc5c99789b367d7415697be8063597e0bc7ec46e Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Fri, 12 Jul 2019 19:05:52 -0400 Subject: [PATCH 071/499] =?UTF-8?q?Refactor=20`validate=5Fpartial=5Fpermis?= =?UTF-8?q?sions()`=20and=20add=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `absolute_resolve()` utility function --- kpi/serializers/v2/asset_permission.py | 101 ++++++++++++++----------- 1 file changed, 57 insertions(+), 44 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 5c1d0857fd..150f459a66 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -15,6 +15,7 @@ RelativePrefixHyperlinkedRelatedField from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission +from kpi.utils.urls import absolute_resolve class AssetPermissionSerializer(serializers.ModelSerializer): @@ -93,53 +94,65 @@ def validate_partial_permissions(self, attrs): If data is valid, `partial_permissions` attribute is added to `attrs`. Useful to permission assignment in `create()`. - :param attrs: dict. - :return: dict. + :param attrs: dict of `{'user': '', + 'permission': }` + :return: dict, the `attrs` parameter updated (if necessary) with + validated `partial_permissions` dicts. """ - permission = attrs.get('permission') - request = self.context.get('request') - if isinstance(request.data, dict): + permission = attrs['permission'] + if not permission.codename.startswith(PREFIX_PARTIAL_PERMS): + # No additional validation needed + return attrs + + def _invalid_partial_permissions(message): + raise serializers.ValidationError( + {'partial_permissions': message} + ) + + request = self.context['request'] + if isinstance(request.data, dict): # for a single assignment partial_permissions = request.data.get('partial_permissions') - elif self.context.get('partial_permissions'): + elif self.context.get('partial_permissions'): # injected during bulk assignment partial_permissions = self.context.get('partial_permissions') + + if not partial_permissions: + _invalid_partial_permissions( + _("This field is required for the '{}' permission").format( + permission.codename + ) + ) + partial_permissions_attr = defaultdict(list) - if permission.codename.startswith(PREFIX_PARTIAL_PERMS): - if partial_permissions: - is_valid = True - - for partial_permission, filter_ in \ - self.__get_partial_permissions_generator(partial_permissions): - try: - parse_result = urlparse(partial_permission.get('url')) - resolver = resolve(parse_result.path) - codename = resolver.kwargs.get('codename') - except (AttributeError, Resolver404): - is_valid = False - break # No need to go further - - # Permission must valid and must be assignable. - # Ensure `filter_` is a `dict`. - # No need to validate Mongo syntax, query will fail - # if syntax is not correct. - if (self._validate_permission(codename, - SUFFIX_SUBMISSIONS_PERMS) - and isinstance(filter_, dict)): - partial_permissions_attr[codename].append(filter_) - continue - - is_valid = False - break # No need to go further - - if not is_valid: - raise serializers.ValidationError(_('Invalid partial permissions')) - - # Everything went well. Add it to `attrs` - attrs.update({'partial_permissions': partial_permissions_attr}) - else: - raise serializers.ValidationError( - _("Can not assign '{}' permission. Partial permissions " - "are missing.").format(permission.codename)) + for partial_permission, filters_ in \ + self.__get_partial_permissions_generator(partial_permissions): + try: + resolver_match = absolute_resolve( + partial_permission.get('url') + ) + except (TypeError, Resolver404): + _invalid_partial_permissions(_('Invalid `url`')) + + try: + codename = resolver_match.kwargs['codename'] + except KeyError: + _invalid_partial_permissions(_('Invalid `url`')) + + # Permission must valid and must be assignable. + if not self._validate_permission(codename, + SUFFIX_SUBMISSIONS_PERMS): + _invalid_partial_permissions(_('Invalid `url`')) + + # No need to validate Mongo syntax, query will fail + # if syntax is not correct. + if not isinstance(filters_, dict): + _invalid_partial_permissions(_('Invalid `filters`')) + + # Validation passed! + partial_permissions_attr[codename].append(filters_) + + # Everything went well. Add it to `attrs` + attrs.update({'partial_permissions': partial_permissions_attr}) return attrs @@ -186,8 +199,8 @@ def __get_partial_permissions_generator(self, partial_permissions): :return: generator """ for partial_permission in partial_permissions: - for filter_ in partial_permission.get('filters'): - yield partial_permission, filter_ + for filters_ in partial_permission.get('filters'): + yield partial_permission, filters_ def __get_permission_hyperlink(self, codename): """ From 8748f411302700518b9cff7f62f70ae18db20c72 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 30 Apr 2019 17:57:53 +0200 Subject: [PATCH 072/499] remove unused classname --- .../js/components/modalForms/sharingForm.es6 | 392 ++++++++++++++++++ .../modalForms/translationSettings.es6 | 2 +- 2 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 jsapp/js/components/modalForms/sharingForm.es6 diff --git a/jsapp/js/components/modalForms/sharingForm.es6 b/jsapp/js/components/modalForms/sharingForm.es6 new file mode 100644 index 0000000000..9d0e9fe0a5 --- /dev/null +++ b/jsapp/js/components/modalForms/sharingForm.es6 @@ -0,0 +1,392 @@ +import _ from 'underscore'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import reactMixin from 'react-mixin'; +import autoBind from 'react-autobind'; +import Reflux from 'reflux'; +import TagsInput from 'react-tagsinput'; +import classNames from 'classnames'; +import Select from 'react-select'; +import Checkbox from 'js/components/checkbox'; +import mixins from 'js/mixins'; +import stores from 'js/stores'; +import actions from 'js/actions'; +import bem from 'js/bem'; +import { + t, + parsePermissions, + stringToColor, + anonUsername +} from 'utils'; + +// parts +import CopyTeamPermissions from './copyTeamPermissions'; + +var availablePermissions = [ + {value: 'view', label: t('View Form')}, + {value: 'change', label: t('Edit Form')}, + {value: 'view_submissions', label: t('View Submissions')}, + {value: 'add_submissions', label: t('Add Submissions')}, + {value: 'change_submissions', label: t('Edit Submissions')}, + {value: 'validate_submissions', label: t('Validate Submissions')} +]; + +class UserPermDiv extends React.Component { + constructor(props) { + super(props); + autoBind(this); + } + removePermissions() { + // removing view permission will include all other permissions + actions.permissions.removePerm({ + permission_url: this.props.can.view.url, + content_object_uid: this.props.uid + }); + } + PermOnChange(perm) { + var cans = this.props.can; + if (perm) { + var permName = perm.value; + this.setPerm(permName, this.props); + if (permName == 'view' && cans.change) + this.removePerm('change', cans.change, this.props.uid); + } else { + if (cans.view) + this.removePerm('view', cans.view, this.props.uid); + if (cans.change) + this.removePerm('change', cans.change, this.props.uid); + } + + } + render () { + var initialsStyle = { + background: `#${stringToColor(this.props.username)}` + }; + + var cans = []; + for (var key in this.props.can) { + let perm = availablePermissions.find(function (d) {return d.value === key}); + if (perm && perm.label) + cans.push(perm.label); + } + + const cansString = cans.sort().join(', '); + + return ( + 0 ? 'regular' : 'deleted'}> + + + {this.props.username.charAt(0)} + + + + {this.props.username} + + + {cansString} + + + + + + ); + } +}; + +reactMixin(UserPermDiv.prototype, mixins.permissions); + +class PublicPermDiv extends React.Component { + constructor(props) { + super(props); + autoBind(this); + } + togglePerms(permRole) { + var permission = this.props.publicPerms.filter(function(perm){ return perm.permission === permRole })[0]; + + if (permission) { + actions.permissions.removePerm({ + permission_url: permission.url, + content_object_uid: this.props.uid + }); + } else { + actions.permissions.assignPerm({ + username: anonUsername, + uid: this.props.uid, + kind: this.props.kind, + objectUrl: this.props.objectUrl, + role: permRole === 'view_asset' ? 'view' : permRole + }); + } + } + render () { + var uid = this.props.uid; + + var href = `#/forms/${uid}`; + var url = `${window.location.protocol}//${window.location.host}/${href}`; + + var anonCanView = this.props.publicPerms.filter(function(perm){ return perm.permission === 'view_asset' })[0]; + var anonCanViewData = this.props.publicPerms.filter(function(perm){ return perm.permission === 'view_submissions' })[0]; + + return ( + + + + { anonCanView && + + + + + } + + { this.props.deploymentActive && + + + + } + + ); + } +}; + +reactMixin(PublicPermDiv.prototype, mixins.permissions); + +class SharingForm extends React.Component { + constructor(props) { + super(props); + this.state = { + userInputStatus: false, + permInput: 'view' + }; + this._usernameCheckDebounced = _.debounce(this._usernameCheckCall.bind(this), 500); + autoBind(this); + } + assetChange (data) { + var uid = this.props.uid || this.currentAssetID(), + asset = data[uid]; + + if (asset) { + this.setState({ + asset: asset, + permissions: asset.permissions, + owner: asset.owner__username, + pperms: parsePermissions(asset.owner__username, asset.permissions), + public_permissions: asset.permissions.filter(function(perm){ return perm.user__username === anonUsername }), + related_users: stores.asset.relatedUsers[uid] + }); + } + } + componentDidMount () { + this.listenTo(stores.userExists, this.userExistsStoreChange); + if (this.props.uid) { + actions.resources.loadAsset({id: this.props.uid}); + } + this.listenTo(stores.asset, this.assetChange); + } + userExistsStoreChange (checked, result) { + var inpVal = this.usernameFieldValue(); + if (inpVal === result) { + var newStatus = checked[result] ? 'success' : 'error'; + this.setState({ + userInputStatus: newStatus + }); + } + } + usernameField () { + return ReactDOM.findDOMNode(this.refs.usernameInput); + } + usernameFieldValue () { + return this.usernameField().value; + } + usernameCheck (evt) { + evt.persist(); + this._usernameCheckDebounced(evt); + } + _usernameCheckCall (evt) { + var username = evt.target.value; + if (username && username.length > 1) { + var result = stores.userExists.checkUsername(username); + if (result === undefined) { + actions.misc.checkUsername(username); + } else { + this.setState({ + userInputStatus: result ? 'success' : 'error' + }); + } + } else { + this.setState({ + userInputStatus: false + }); + } + } + addInitialUserPermission (evt) { + evt.preventDefault(); + var username = this.usernameFieldValue(); + if (stores.userExists.checkUsername(username)) { + actions.permissions.assignPerm({ + username: username, + uid: this.state.asset.uid, + kind: this.state.asset.kind, + objectUrl: this.props.objectUrl, + role: this.state.permInput.value + }); + this.usernameField().value = ''; + } + } + updatePermInput(perm) { + this.setState({ + permInput: perm + }); + } + render () { + var inpStatus = this.state.userInputStatus; + if (!this.state.pperms) { + return ( + + + + {t('loading...')} + + + ); + } + var _perms = this.state.pperms; + var perms = this.state.related_users.map(function(username){ + var currentPerm = _perms.filter(function(p){ + return p.username === username; + })[0]; + if (currentPerm) { + return currentPerm; + } else { + return { + username: username, + can: {} + }; + } + }); + + var btnKls = classNames('mdl-button', 'mdl-button--raised', inpStatus === 'success' ? 'mdl-button--colored' : 'mdl-button--disabled'); + + let uid = this.state.asset.uid, + kind = this.state.asset.kind, + asset_type = this.state.asset.asset_type, + objectUrl = this.state.asset.url, + name = this.state.asset.name; + + if (!perms) { + return ( +

loading

+ ); + } + + var initialsStyle = { + background: `#${stringToColor(this.state.asset.owner__username)}` + }; + + if (asset_type != 'survey') { + availablePermissions = [ + {value: 'view', label: t('View')}, + {value: 'change', label: t('Edit')}, + ]; + } + + return ( + + + + {name} + + + {t('Who has access')} + + + + + {this.state.asset.owner__username.charAt(0)} + + + +
{this.state.asset.owner__username}
+
+ {t('is owner')} +
+ + {perms.map((perm)=> { + return ; + })} + +
+ + + + {t('Invite collaborators')} + + + + - + Date: Tue, 30 Apr 2019 23:45:43 +0200 Subject: [PATCH 074/499] username validation --- .../js/components/modalForms/sharingForm.es6 | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/jsapp/js/components/modalForms/sharingForm.es6 b/jsapp/js/components/modalForms/sharingForm.es6 index 188a50b115..487464efee 100644 --- a/jsapp/js/components/modalForms/sharingForm.es6 +++ b/jsapp/js/components/modalForms/sharingForm.es6 @@ -56,8 +56,9 @@ class UserPermissionEditor extends React.Component { } componentDidMount() { - // TODO set permissions if given (i.e. editing existing permissions, - // not giving new) + // TODO set permissions from props if given (i.e. editing existing permissions vs giving new) + + this.listenTo(stores.userExists, this.onUserExistsStoreChange); } togglePerm(permId) { @@ -70,16 +71,27 @@ class UserPermissionEditor extends React.Component { this.setState({username: username}); } - restrictedUsersChange(users) { - this.setState({restricted_view_users: users}); + restrictedUsersChange(allUsers, changedUsers) { + this.setState({restricted_view_users: allUsers}); + changedUsers.forEach((username) => { + this.callCheckUsername(username); + }) + console.log('restrictedUsersChange', users); } - validateUsername(username) { - return username !== 'leszek'; + callCheckUsername(username) { + const storesResult = stores.userExists.checkUsername(username); + if (storesResult === undefined) { + actions.misc.checkUsername(username); + } else { + onUserExistsStoreChange(result); + } } - onValidateUsernameReject(arr) { - console.log(arr); + onUserExistsStoreChange(result) { + // TODO: check if username exists in `restricted_view_users` + // if yes, if result false, remove it and display error message to user + console.log('onUserExistsStoreChange', result); } render() { @@ -116,9 +128,8 @@ class UserPermissionEditor extends React.Component { } @@ -157,6 +168,7 @@ class UserPermissionEditor extends React.Component { ); } } +reactMixin(UserPermissionEditor.prototype, Reflux.ListenerMixin); class UserPermDiv extends React.Component { constructor(props) { From 23b3bb6e1720027bab6b2459e165ab4e74c11b8a Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 30 Apr 2019 23:49:44 +0200 Subject: [PATCH 075/499] fix undef err --- jsapp/js/components/modalForms/sharingForm.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/components/modalForms/sharingForm.es6 b/jsapp/js/components/modalForms/sharingForm.es6 index 487464efee..9d31d8b64d 100644 --- a/jsapp/js/components/modalForms/sharingForm.es6 +++ b/jsapp/js/components/modalForms/sharingForm.es6 @@ -76,7 +76,7 @@ class UserPermissionEditor extends React.Component { changedUsers.forEach((username) => { this.callCheckUsername(username); }) - console.log('restrictedUsersChange', users); + console.log('restrictedUsersChange', allUsers, changedUsers); } callCheckUsername(username) { From 858b9b4eba0e3273b85fbeda4110f5d24f9ca17f Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 3 May 2019 17:24:36 +0200 Subject: [PATCH 076/499] username checking --- .../js/components/modalForms/sharingForm.es6 | 78 ++++++++++++++----- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/jsapp/js/components/modalForms/sharingForm.es6 b/jsapp/js/components/modalForms/sharingForm.es6 index 9d31d8b64d..cd8332e543 100644 --- a/jsapp/js/components/modalForms/sharingForm.es6 +++ b/jsapp/js/components/modalForms/sharingForm.es6 @@ -16,6 +16,7 @@ import actions from 'js/actions'; import bem from 'js/bem'; import { t, + notify, parsePermissions, stringToColor, anonUsername @@ -51,7 +52,8 @@ class UserPermissionEditor extends React.Component { change_submissions: false, validate_submissions: false, restricted_view: false, - restricted_view_users: [] + restricted_view_users: [], + isAwaitingAsyncResponse: false }; } @@ -59,6 +61,7 @@ class UserPermissionEditor extends React.Component { // TODO set permissions from props if given (i.e. editing existing permissions vs giving new) this.listenTo(stores.userExists, this.onUserExistsStoreChange); + this.listenTo(actions.misc.checkUsername.completed, this.onCheckUsernameActionCompleted); } togglePerm(permId) { @@ -72,26 +75,59 @@ class UserPermissionEditor extends React.Component { } restrictedUsersChange(allUsers, changedUsers) { - this.setState({restricted_view_users: allUsers}); - changedUsers.forEach((username) => { - this.callCheckUsername(username); + const restrictedUsers = []; + + allUsers.forEach((username) => { + const userCheck = this.checkUsernameSync(username); + if (userCheck === true) { + restrictedUsers.push(username); + } else if (userCheck === undefined) { + // we add unknown usernames for now and will check with checkUsernameAsync + restrictedUsers.push(username); + this.checkUsernameAsync(username); + } else { + this.notifyUnknownUser(username); + } }) - console.log('restrictedUsersChange', allUsers, changedUsers); + + this.setState({restricted_view_users: restrictedUsers}); } - callCheckUsername(username) { - const storesResult = stores.userExists.checkUsername(username); - if (storesResult === undefined) { - actions.misc.checkUsername(username); - } else { - onUserExistsStoreChange(result); - } + /** + * This function returns either boolean (for known username) or undefined + */ + checkUsernameSync(username) { + return stores.userExists.checkUsername(username); + } + + /** + * This function calls API and relies on onUserExistsStoreChange callback + */ + checkUsernameAsync(username) { + actions.misc.checkUsername(username); + this.setState({isAwaitingAsyncResponse: true}); } + onCheckUsernameActionCompleted() { + this.setState({isAwaitingAsyncResponse: false}); + } + + notifyUnknownUser(username) { + notify(`${t('User not found:')} ${username}`, 'warning'); + } + + /** + * Remove nonexistent usernames from tagsinput array + */ onUserExistsStoreChange(result) { - // TODO: check if username exists in `restricted_view_users` - // if yes, if result false, remove it and display error message to user - console.log('onUserExistsStoreChange', result); + const restrictedUsers = this.state.restricted_view_users; + restrictedUsers.forEach((username) => { + if (result[username] === false) { + restrictedUsers.pop(restrictedUsers.indexOf(username)); + this.notifyUnknownUser(username); + } + }); + this.setState({restricted_view_users: restrictedUsers}); } render() { @@ -164,6 +200,12 @@ class UserPermissionEditor extends React.Component { onChange={this.togglePerm.bind(this, 'validate_submissions')} label={AVAILABLE_PERMISSIONS.get('validate_submissions')} /> + + + {t('Submit')} + ); } @@ -353,9 +395,9 @@ class SharingForm extends React.Component { _usernameCheckCall (evt) { var username = evt.target.value; if (username && username.length > 1) { - var result = stores.userExists.checkUsername(username); + var result = stores.userExists.checkUsernameSync(username); if (result === undefined) { - actions.misc.checkUsername(username); + actions.misc.checkUsernameSync(username); } else { this.setState({ userInputStatus: result ? 'success' : 'error' @@ -370,7 +412,7 @@ class SharingForm extends React.Component { addInitialUserPermission (evt) { evt.preventDefault(); var username = this.usernameFieldValue(); - if (stores.userExists.checkUsername(username)) { + if (stores.userExists.checkUsernameSync(username)) { actions.permissions.assignPerm({ username: username, uid: this.state.asset.uid, From 8a81d5b70e929ce92c8e1354d850c545f4dae776 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Mon, 6 May 2019 22:48:49 +0200 Subject: [PATCH 077/499] split userPermissionsEditor form sharingForm, WIP --- .../js/components/modalForms/sharingForm.es6 | 598 ------------------ .../permissions/userPermissionsEditor.es6 | 265 ++++++++ 2 files changed, 265 insertions(+), 598 deletions(-) delete mode 100644 jsapp/js/components/modalForms/sharingForm.es6 create mode 100644 jsapp/js/components/permissions/userPermissionsEditor.es6 diff --git a/jsapp/js/components/modalForms/sharingForm.es6 b/jsapp/js/components/modalForms/sharingForm.es6 deleted file mode 100644 index cd8332e543..0000000000 --- a/jsapp/js/components/modalForms/sharingForm.es6 +++ /dev/null @@ -1,598 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import reactMixin from 'react-mixin'; -import autoBind from 'react-autobind'; -import Reflux from 'reflux'; -import TagsInput from 'react-tagsinput'; -import classNames from 'classnames'; -import Select from 'react-select'; -import Checkbox from 'js/components/checkbox'; -import TextBox from 'js/components/textbox'; -import mixins from 'js/mixins'; -import stores from 'js/stores'; -import actions from 'js/actions'; -import bem from 'js/bem'; -import { - t, - notify, - parsePermissions, - stringToColor, - anonUsername -} from 'js/utils'; -import { - AVAILABLE_PERMISSIONS -} from 'js/constants'; - -// parts -import CopyTeamPermissions from './copyTeamPermissions'; - -var availablePermissions = [ - {value: 'view', label: t('View Form')}, - {value: 'change', label: t('Edit Form')}, - {value: 'view_submissions', label: t('View Submissions')}, - {value: 'add_submissions', label: t('Add Submissions')}, - {value: 'change_submissions', label: t('Edit Submissions')}, - {value: 'validate_submissions', label: t('Validate Submissions')} -]; - -class UserPermissionEditor extends React.Component { - constructor(props) { - super(props); - autoBind(this); - - this.state = { - username: '', - usernameError: '', - view: false, - change: false, - view_submissions: false, - add_submissions: false, - change_submissions: false, - validate_submissions: false, - restricted_view: false, - restricted_view_users: [], - isAwaitingAsyncResponse: false - }; - } - - componentDidMount() { - // TODO set permissions from props if given (i.e. editing existing permissions vs giving new) - - this.listenTo(stores.userExists, this.onUserExistsStoreChange); - this.listenTo(actions.misc.checkUsername.completed, this.onCheckUsernameActionCompleted); - } - - togglePerm(permId) { - let newPerms = {}; - newPerms[permId] = !this.state[permId]; - this.setState(newPerms); - } - - usernameChange(username) { - this.setState({username: username}); - } - - restrictedUsersChange(allUsers, changedUsers) { - const restrictedUsers = []; - - allUsers.forEach((username) => { - const userCheck = this.checkUsernameSync(username); - if (userCheck === true) { - restrictedUsers.push(username); - } else if (userCheck === undefined) { - // we add unknown usernames for now and will check with checkUsernameAsync - restrictedUsers.push(username); - this.checkUsernameAsync(username); - } else { - this.notifyUnknownUser(username); - } - }) - - this.setState({restricted_view_users: restrictedUsers}); - } - - /** - * This function returns either boolean (for known username) or undefined - */ - checkUsernameSync(username) { - return stores.userExists.checkUsername(username); - } - - /** - * This function calls API and relies on onUserExistsStoreChange callback - */ - checkUsernameAsync(username) { - actions.misc.checkUsername(username); - this.setState({isAwaitingAsyncResponse: true}); - } - - onCheckUsernameActionCompleted() { - this.setState({isAwaitingAsyncResponse: false}); - } - - notifyUnknownUser(username) { - notify(`${t('User not found:')} ${username}`, 'warning'); - } - - /** - * Remove nonexistent usernames from tagsinput array - */ - onUserExistsStoreChange(result) { - const restrictedUsers = this.state.restricted_view_users; - restrictedUsers.forEach((username) => { - if (result[username] === false) { - restrictedUsers.pop(restrictedUsers.indexOf(username)); - this.notifyUnknownUser(username); - } - }); - this.setState({restricted_view_users: restrictedUsers}); - } - - render() { - const restrictedViewUsersInputProps = { - placeholder: t('Add username(s)') - }; - - return ( - - {t('Grant permissions to')} - - - - - - {this.state.view === true && -
- - - {this.state.restricted_view === true && - - } -
- } - - - - - - - - - - - - - {t('Submit')} - -
- ); - } -} -reactMixin(UserPermissionEditor.prototype, Reflux.ListenerMixin); - -class UserPermDiv extends React.Component { - constructor(props) { - super(props); - autoBind(this); - } - removePermissions() { - // removing view permission will include all other permissions - actions.permissions.removePerm({ - permission_url: this.props.can.view.url, - content_object_uid: this.props.uid - }); - } - PermOnChange(perm) { - var cans = this.props.can; - if (perm) { - var permName = perm.value; - this.setPerm(permName, this.props); - if (permName == 'view' && cans.change) - this.removePerm('change', cans.change, this.props.uid); - } else { - if (cans.view) - this.removePerm('view', cans.view, this.props.uid); - if (cans.change) - this.removePerm('change', cans.change, this.props.uid); - } - - } - render () { - var initialsStyle = { - background: `#${stringToColor(this.props.username)}` - }; - - var cans = []; - for (var key in this.props.can) { - let perm = availablePermissions.find(function (d) {return d.value === key}); - if (perm && perm.label) - cans.push(perm.label); - } - - const cansString = cans.sort().join(', '); - - return ( - 0 ? 'regular' : 'deleted'}> - - - {this.props.username.charAt(0)} - - - - {this.props.username} - - - {cansString} - - - - - - ); - } -} - -reactMixin(UserPermDiv.prototype, mixins.permissions); - -class PublicPermDiv extends React.Component { - constructor(props) { - super(props); - autoBind(this); - } - togglePerms(permRole) { - var permission = this.props.publicPerms.filter(function(perm){ return perm.permission === permRole })[0]; - - if (permission) { - actions.permissions.removePerm({ - permission_url: permission.url, - content_object_uid: this.props.uid - }); - } else { - actions.permissions.assignPerm({ - username: anonUsername, - uid: this.props.uid, - kind: this.props.kind, - objectUrl: this.props.objectUrl, - role: permRole === 'view_asset' ? 'view' : permRole - }); - } - } - render () { - var uid = this.props.uid; - - var href = `#/forms/${uid}`; - var url = `${window.location.protocol}//${window.location.host}/${href}`; - - var anonCanView = this.props.publicPerms.filter(function(perm){ return perm.permission === 'view_asset' })[0]; - var anonCanViewData = this.props.publicPerms.filter(function(perm){ return perm.permission === 'view_submissions' })[0]; - - return ( - - - - { anonCanView && - - - - - } - - { this.props.deploymentActive && - - - - } - - ); - } -}; - -reactMixin(PublicPermDiv.prototype, mixins.permissions); - -class SharingForm extends React.Component { - constructor(props) { - super(props); - this.state = { - userInputStatus: false, - permInput: 'view' - }; - this._usernameCheckDebounced = _.debounce(this._usernameCheckCall.bind(this), 500); - autoBind(this); - } - assetChange (data) { - var uid = this.props.uid || this.currentAssetID(), - asset = data[uid]; - - if (asset) { - this.setState({ - asset: asset, - permissions: asset.permissions, - owner: asset.owner__username, - pperms: parsePermissions(asset.owner__username, asset.permissions), - public_permissions: asset.permissions.filter(function(perm){ return perm.user__username === anonUsername }), - related_users: stores.asset.relatedUsers[uid] - }); - } - } - componentDidMount () { - this.listenTo(stores.userExists, this.userExistsStoreChange); - if (this.props.uid) { - actions.resources.loadAsset({id: this.props.uid}); - } - this.listenTo(stores.asset, this.assetChange); - } - userExistsStoreChange (checked, result) { - var inpVal = this.usernameFieldValue(); - if (inpVal === result) { - var newStatus = checked[result] ? 'success' : 'error'; - this.setState({ - userInputStatus: newStatus - }); - } - } - usernameField () { - return ReactDOM.findDOMNode(this.refs.usernameInput); - } - usernameFieldValue () { - return this.usernameField().value; - } - usernameCheck (evt) { - evt.persist(); - this._usernameCheckDebounced(evt); - } - _usernameCheckCall (evt) { - var username = evt.target.value; - if (username && username.length > 1) { - var result = stores.userExists.checkUsernameSync(username); - if (result === undefined) { - actions.misc.checkUsernameSync(username); - } else { - this.setState({ - userInputStatus: result ? 'success' : 'error' - }); - } - } else { - this.setState({ - userInputStatus: false - }); - } - } - addInitialUserPermission (evt) { - evt.preventDefault(); - var username = this.usernameFieldValue(); - if (stores.userExists.checkUsernameSync(username)) { - actions.permissions.assignPerm({ - username: username, - uid: this.state.asset.uid, - kind: this.state.asset.kind, - objectUrl: this.props.objectUrl, - role: this.state.permInput.value - }); - this.usernameField().value = ''; - } - } - updatePermInput(perm) { - this.setState({ - permInput: perm - }); - } - - toggleAddUser() { - this.setState({isAddUserEditorVisible: !this.state.isAddUserEditorVisible}); - } - - render () { - var inpStatus = this.state.userInputStatus; - if (!this.state.pperms) { - return ( - - - - {t('loading...')} - - - ); - } - var _perms = this.state.pperms; - var perms = this.state.related_users.map(function(username){ - var currentPerm = _perms.filter(function(p){ - return p.username === username; - })[0]; - if (currentPerm) { - return currentPerm; - } else { - return { - username: username, - can: {} - }; - } - }); - - var btnKls = classNames('mdl-button', 'mdl-button--raised', inpStatus === 'success' ? 'mdl-button--colored' : 'mdl-button--disabled'); - - let uid = this.state.asset.uid, - kind = this.state.asset.kind, - asset_type = this.state.asset.asset_type, - objectUrl = this.state.asset.url, - name = this.state.asset.name; - - if (!perms) { - return ( -

loading

- ); - } - - var initialsStyle = { - background: `#${stringToColor(this.state.asset.owner__username)}` - }; - - if (asset_type != 'survey') { - availablePermissions = [ - {value: 'view', label: t('View')}, - {value: 'change', label: t('Edit')}, - ]; - } - - return ( - - - - {name} - - - {t('Who has access')} - - - - - {this.state.asset.owner__username.charAt(0)} - - - -
{this.state.asset.owner__username}
-
- {t('is owner')} -
- - {perms.map((perm)=> { - return ; - })} - -
- - - {!this.state.isAddUserEditorVisible && - - {t('Add user')} - - } - {this.state.isAddUserEditorVisible && - - - - - - - - } - - - - { @@ -115,7 +115,8 @@ class UserPermissionsEditor extends React.Component { if (userCheck === true) { restrictedUsers.push(username); } else if (userCheck === undefined) { - // we add unknown usernames for now and will check with checkUsernameAsync + // we add unknown usernames for now and will check and possibly remove + // with checkUsernameAsync restrictedUsers.push(username); this.checkUsernameAsync(username); } else { @@ -128,6 +129,7 @@ class UserPermissionsEditor extends React.Component { /** * This function returns either boolean (for known username) or undefined + * for usernames that weren't checked before */ checkUsernameSync(username) { return stores.userExists.checkUsername(username); @@ -247,7 +249,7 @@ class UserPermissionsEditor extends React.Component { {this.state.restricted_view === true && From 6478a1d98860010ed4147fa9f2b8a37432859d7a Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Mon, 13 May 2019 11:29:55 +0200 Subject: [PATCH 082/499] rename restricted to partial --- .../permissions/userPermissionsEditor.es6 | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 9113979e2b..33d4df95ce 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -35,8 +35,8 @@ class UserPermissionsEditor extends React.Component { add_submissions: false, change_submissions: false, validate_submissions: false, - restricted_view: false, - restricted_view_users: [], + partial_view: false, + partial_view_users: [], usernamesBeingChecked: new Set() }; } @@ -107,24 +107,24 @@ class UserPermissionsEditor extends React.Component { } } - onRestrictedUsersChange(allUsers) { - const restrictedUsers = []; + onPartialViewUsersChange(allUsers) { + const partialViewUsers = []; allUsers.forEach((username) => { const userCheck = this.checkUsernameSync(username); if (userCheck === true) { - restrictedUsers.push(username); + partialViewUsers.push(username); } else if (userCheck === undefined) { // we add unknown usernames for now and will check and possibly remove // with checkUsernameAsync - restrictedUsers.push(username); + partialViewUsers.push(username); this.checkUsernameAsync(username); } else { this.notifyUnknownUser(username); } }); - this.setState({restricted_view_users: restrictedUsers}); + this.setState({partial_view_users: partialViewUsers}); } /** @@ -153,15 +153,15 @@ class UserPermissionsEditor extends React.Component { * Remove nonexistent usernames from tagsinput array */ onUserExistsStoreChange(result) { - // check restricted users - const restrictedUsers = this.state.restricted_view_users; - restrictedUsers.forEach((username) => { + // check partial view users + const partialViewUsers = this.state.partial_view_users; + partialViewUsers.forEach((username) => { if (result[username] === false) { - restrictedUsers.pop(restrictedUsers.indexOf(username)); + partialViewUsers.pop(partialViewUsers.indexOf(username)); this.notifyUnknownUser(username); } }); - this.setState({restricted_view_users: restrictedUsers}); + this.setState({partial_view_users: partialViewUsers}); // check username if (result[this.state.username] === false) { @@ -208,7 +208,7 @@ class UserPermissionsEditor extends React.Component { } render() { - const restrictedViewUsersInputProps = { + const partialViewUsersInputProps = { placeholder: t('Add username(s)') }; @@ -241,16 +241,16 @@ class UserPermissionsEditor extends React.Component { {this.state.view === true &&
- {this.state.restricted_view === true && + {this.state.partial_view === true && } From 8dc56260dac2b1682f34cfa84953c05793ed6da3 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 14 May 2019 22:52:33 +0200 Subject: [PATCH 083/499] user permParser directly --- jsapp/js/components/permissions/permConfig.es6 | 1 - jsapp/js/components/permissions/userPermissionsEditor.es6 | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/jsapp/js/components/permissions/permConfig.es6 b/jsapp/js/components/permissions/permConfig.es6 index 3b4643ab9d..531d5c7737 100644 --- a/jsapp/js/components/permissions/permConfig.es6 +++ b/jsapp/js/components/permissions/permConfig.es6 @@ -53,7 +53,6 @@ const permConfig = Reflux.createStore({ this.trigger(changed); } }, - onGetConfigCompleted(response) { this.setState({permissions: response.results}); }, diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 33d4df95ce..df9b37d735 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -4,7 +4,7 @@ import autoBind from 'react-autobind'; import Reflux from 'reflux'; import TagsInput from 'react-tagsinput'; import Checkbox from 'js/components/checkbox'; -import TextBox from 'js/components/textbox'; +import TextBox from 'js/components/textBox'; import stores from 'js/stores'; import actions from 'js/actions'; import bem from 'js/bem'; From d8da6d3bc813f4b6a2e2c4e54d595ef9fe03590a Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 14 May 2019 23:16:58 +0200 Subject: [PATCH 084/499] define new component row for styling --- .../permissions/userPermissionsEditor.es6 | 150 ++++++++++-------- 1 file changed, 81 insertions(+), 69 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index df9b37d735..826ccc2dfc 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -8,6 +8,7 @@ import TextBox from 'js/components/textBox'; import stores from 'js/stores'; import actions from 'js/actions'; import bem from 'js/bem'; +import classNames from 'classnames'; import { t, notify @@ -220,80 +221,91 @@ class UserPermissionsEditor extends React.Component { return ( {t('Grant permissions to')} - - - - - {this.state.view === true && -
- - - {this.state.partial_view === true && - + +
+ +
+ + + {this.state.view === true && +
+ - } -
- } - - - - - - - - - - - - - {this.props.username ? t('Update') : t('Submit')} - + + {this.state.partial_view === true && + + } +
+ } +
+ +
+ + + + + + + + + +
+ +
+ + {this.props.username ? t('Update') : t('Submit')} + +
); } From 60f0d201e51bda7a160b4bd17cb16678b57cf7e2 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 14 May 2019 23:20:16 +0200 Subject: [PATCH 085/499] style tweaks --- jsapp/js/components/permissions/userPermissionsEditor.es6 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 826ccc2dfc..593e5cb130 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -224,7 +224,9 @@ class UserPermissionsEditor extends React.Component { className='user-permissions-editor' onSubmit={this.submit} > - {t('Grant permissions to')} +
+ {t('Grant permissions to')} +
Date: Wed, 15 May 2019 12:53:25 +0200 Subject: [PATCH 086/499] permConfig has availablePermissions --- jsapp/js/components/permissions/permConfig.es6 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/jsapp/js/components/permissions/permConfig.es6 b/jsapp/js/components/permissions/permConfig.es6 index 531d5c7737..d480dd7273 100644 --- a/jsapp/js/components/permissions/permConfig.es6 +++ b/jsapp/js/components/permissions/permConfig.es6 @@ -92,6 +92,23 @@ const permConfig = Reflux.createStore({ if (this.state.permissions.length === 0) { throw new Error(t('Permission config is not ready or failed to initialize!')); } + }, + getAvailablePermissions(assetType) { + if (assetType === 'survey') { + return [ + {value: 'view', label: t('View Form')}, + {value: 'change', label: t('Edit Form')}, + {value: 'view_submissions', label: t('View Submissions')}, + {value: 'add_submissions', label: t('Add Submissions')}, + {value: 'change_submissions', label: t('Edit Submissions')}, + {value: 'validate_submissions', label: t('Validate Submissions')} + ]; + } else { + return [ + {value: 'view', label: t('View')}, + {value: 'change', label: t('Edit')}, + ]; + } } }); From caad304a44b956a591113a4ffae61a74baf85f8d Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 15 May 2019 12:53:39 +0200 Subject: [PATCH 087/499] apply props data in userPermissionsEditor --- .../permissions/userPermissionsEditor.es6 | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 593e5cb130..effdb03fbe 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -27,9 +27,12 @@ class UserPermissionsEditor extends React.Component { autoBind(this); this.state = { + // inner workings + usernamesBeingChecked: new Set(), isSubmitPending: false, - username: '', isEditingUsername: false, + // permissions related data + username: '', view: false, change: false, view_submissions: false, @@ -37,15 +40,27 @@ class UserPermissionsEditor extends React.Component { change_submissions: false, validate_submissions: false, partial_view: false, - partial_view_users: [], - usernamesBeingChecked: new Set() + partial_view_users: [] }; + + this.applyPropsData(); } - componentDidMount() { + /* + * Fills up form with provided user name and permissions (if applicable) + */ + applyPropsData() { // TODO 1: set permissions from props if given // TODO 2: set mode based on props (i.e. editing existing permissions vs giving new) + console.log('applyPropsData', this.props); + + if (this.props.username) { + this.state.username = this.props.username; + } + } + + componentDidMount() { this.listenTo(actions.permissions.assignPerm.completed, this.onAssignPermCompleted); this.listenTo(actions.permissions.assignPerm.failed, this.onAssignPermFailed); this.listenTo(stores.userExists, this.onUserExistsStoreChange); From 0e15b26592567f05b1f8e6ad2b5b0e2b61f033f4 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 15 May 2019 21:51:31 +0200 Subject: [PATCH 088/499] wrapping my head around implied perms --- .../permissions/userPermissionsEditor.es6 | 108 +++++++++--------- jsapp/js/constants.es6 | 1 - 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index effdb03fbe..c3a4d62c22 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -9,13 +9,11 @@ import stores from 'js/stores'; import actions from 'js/actions'; import bem from 'js/bem'; import classNames from 'classnames'; +import permConfig from './permConfig'; import { t, notify } from 'js/utils'; -import { - AVAILABLE_PERMISSIONS -} from 'js/constants'; /** * Displays a form for either giving a new user some permissions, @@ -33,14 +31,14 @@ class UserPermissionsEditor extends React.Component { isEditingUsername: false, // permissions related data username: '', - view: false, - change: false, + view_asset: false, + change_asset: false, view_submissions: false, add_submissions: false, change_submissions: false, validate_submissions: false, - partial_view: false, - partial_view_users: [] + partial_view_submissions: false, + partial_view_submissions_users: [] }; this.applyPropsData(); @@ -123,24 +121,24 @@ class UserPermissionsEditor extends React.Component { } } - onPartialViewUsersChange(allUsers) { - const partialViewUsers = []; + onPartialViewSubmissionsUsersChange(allUsers) { + const partialViewPermissionsUsers = []; allUsers.forEach((username) => { const userCheck = this.checkUsernameSync(username); if (userCheck === true) { - partialViewUsers.push(username); + partialViewPermissionsUsers.push(username); } else if (userCheck === undefined) { // we add unknown usernames for now and will check and possibly remove // with checkUsernameAsync - partialViewUsers.push(username); + partialViewPermissionsUsers.push(username); this.checkUsernameAsync(username); } else { this.notifyUnknownUser(username); } }); - this.setState({partial_view_users: partialViewUsers}); + this.setState({partial_view_submissions_users: partialViewPermissionsUsers}); } /** @@ -170,14 +168,14 @@ class UserPermissionsEditor extends React.Component { */ onUserExistsStoreChange(result) { // check partial view users - const partialViewUsers = this.state.partial_view_users; - partialViewUsers.forEach((username) => { + const partialViewPermissionsUsers = this.state.partial_view_submissions_users; + partialViewPermissionsUsers.forEach((username) => { if (result[username] === false) { - partialViewUsers.pop(partialViewUsers.indexOf(username)); + partialViewPermissionsUsers.pop(partialViewPermissionsUsers.indexOf(username)); this.notifyUnknownUser(username); } }); - this.setState({partial_view_users: partialViewUsers}); + this.setState({partial_view_submissions_users: partialViewPermissionsUsers}); // check username if (result[this.state.username] === false) { @@ -224,7 +222,7 @@ class UserPermissionsEditor extends React.Component { } render() { - const partialViewUsersInputProps = { + const partialViewPermissionsUsersInputProps = { placeholder: t('Add username(s)') }; @@ -253,64 +251,64 @@ class UserPermissionsEditor extends React.Component { />
-
- - - {this.state.view === true && -
- - - {this.state.partial_view === true && - - } -
- } -
-
+
+ + + {this.state.view_submissions === true && +
+ + + {this.state.partial_view_submissions === true && + + } +
+ } +
+
diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index e9b85e8cb3..e2dab1c521 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -140,7 +140,6 @@ export const ASSET_TYPES = { }; export default { - AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, update_states: update_states, VALIDATION_STATUSES: VALIDATION_STATUSES, From 2e8bb043cdef16ee024519d08fc160a90f9fff06 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 16 May 2019 21:43:33 +0200 Subject: [PATCH 089/499] permissions editor variables rework --- .../permissions/userPermissionsEditor.es6 | 190 +++++++++++------- 1 file changed, 118 insertions(+), 72 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index c3a4d62c22..120bc8b5f2 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -9,7 +9,6 @@ import stores from 'js/stores'; import actions from 'js/actions'; import bem from 'js/bem'; import classNames from 'classnames'; -import permConfig from './permConfig'; import { t, notify @@ -29,16 +28,16 @@ class UserPermissionsEditor extends React.Component { usernamesBeingChecked: new Set(), isSubmitPending: false, isEditingUsername: false, - // permissions related data + // form user inputs username: '', - view_asset: false, - change_asset: false, - view_submissions: false, - add_submissions: false, - change_submissions: false, - validate_submissions: false, - partial_view_submissions: false, - partial_view_submissions_users: [] + formView: false, + formEdit: false, + submissionsView: false, + submissionsViewPartial: false, + submissionsViewPartialUsers: [], + submissionsAdd: false, + submissionsEdit: false, + submissionsValidate: false, }; this.applyPropsData(); @@ -78,13 +77,54 @@ class UserPermissionsEditor extends React.Component { } } - /** - * handles changes to permission checkboxes - */ - togglePerm(permId) { - let newPerms = {}; - newPerms[permId] = !this.state[permId]; - this.setState(newPerms); + onFormViewChange(isChecked) { + this.setState({ + formView: isChecked + }); + } + + onFormEditChange(isChecked) { + this.setState({ + formEdit: isChecked + }); + } + + onSubmissionsViewChange(isChecked) { + const newState = { + submissionsView: isChecked + }; + + if (!isChecked) { + // reset partial inputs + newState.submissionsViewPartial = false; + newState.submissionsViewPartialUsers = []; + } + + this.setState(newState); + } + + onSubmissionsViewPartialChange(isChecked) { + this.setState({ + submissionsViewPartial: isChecked + }); + } + + onSubmissionsAddChange(isChecked) { + this.setState({ + submissionsAdd: isChecked + }); + } + + onSubmissionsEditChange(isChecked) { + this.setState({ + submissionsEdit: isChecked + }); + } + + onSubmissionsValidateChange(isChecked) { + this.setState({ + submissionsValidate: isChecked + }); } /** @@ -121,24 +161,24 @@ class UserPermissionsEditor extends React.Component { } } - onPartialViewSubmissionsUsersChange(allUsers) { - const partialViewPermissionsUsers = []; + onSubmissionsViewPartialUsersChange(allUsers) { + const submissionsViewPartialUsers = []; allUsers.forEach((username) => { const userCheck = this.checkUsernameSync(username); if (userCheck === true) { - partialViewPermissionsUsers.push(username); + submissionsViewPartialUsers.push(username); } else if (userCheck === undefined) { // we add unknown usernames for now and will check and possibly remove // with checkUsernameAsync - partialViewPermissionsUsers.push(username); + submissionsViewPartialUsers.push(username); this.checkUsernameAsync(username); } else { this.notifyUnknownUser(username); } }); - this.setState({partial_view_submissions_users: partialViewPermissionsUsers}); + this.setState({submissionsViewPartialUsers: submissionsViewPartialUsers}); } /** @@ -168,14 +208,14 @@ class UserPermissionsEditor extends React.Component { */ onUserExistsStoreChange(result) { // check partial view users - const partialViewPermissionsUsers = this.state.partial_view_submissions_users; - partialViewPermissionsUsers.forEach((username) => { + const submissionsViewPartialUsers = this.state.submissionsViewPartialUsers; + submissionsViewPartialUsers.forEach((username) => { if (result[username] === false) { - partialViewPermissionsUsers.pop(partialViewPermissionsUsers.indexOf(username)); + submissionsViewPartialUsers.pop(submissionsViewPartialUsers.indexOf(username)); this.notifyUnknownUser(username); } }); - this.setState({partial_view_submissions_users: partialViewPermissionsUsers}); + this.setState({submissionsViewPartialUsers: submissionsViewPartialUsers}); // check username if (result[this.state.username] === false) { @@ -222,70 +262,76 @@ class UserPermissionsEditor extends React.Component { } render() { - const partialViewPermissionsUsersInputProps = { + const isNew = this.state.username.length === 0; + + const submissionsViewPartialUsersInputProps = { placeholder: t('Add username(s)') }; - const modifiers = []; + const formModifiers = []; if (this.state.isSubmitPending) { - modifiers.push('pending'); + formModifiers.push('pending'); } + const formClassNames = classNames( + 'user-permissions-editor', + isNew ? 'user-permissions-editor--new' : '' + ); + return ( -
- {t('Grant permissions to')} -
- -
- -
+ {isNew && + // don't display username editor for editing existing user +
+ +
+ }
- {this.state.view_submissions === true && + {this.state.submissionsView === true &&
- {this.state.partial_view_submissions === true && + {this.state.submissionsViewPartial === true && } @@ -294,21 +340,21 @@ class UserPermissionsEditor extends React.Component {
@@ -318,7 +364,7 @@ class UserPermissionsEditor extends React.Component { type='submit' disabled={!this.isSubmitEnabled()} > - {this.props.username ? t('Update') : t('Submit')} + {isNew ? t('Grant permissions') : t('Update permissions')}
From c1c635501c7410b3e8f75ecefbb8e35f14e4e475 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 16 May 2019 21:50:03 +0200 Subject: [PATCH 090/499] permParser better fns --- jsapp/js/components/permissions/permParser.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/components/permissions/permParser.es6 b/jsapp/js/components/permissions/permParser.es6 index 141d04e19f..4e8d02ac48 100644 --- a/jsapp/js/components/permissions/permParser.es6 +++ b/jsapp/js/components/permissions/permParser.es6 @@ -329,4 +329,4 @@ export const permParser = { parseOldBackendData: parseOldBackendData, parseUserWithPermsList: parseUserWithPermsList, sortParseBackendOutput: sortParseBackendOutput // for testing purposes -}; \ No newline at end of file +}; From 62640ec86ede7753e40b16e60f2d8f5f0f7fe9b4 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 16 May 2019 22:40:04 +0200 Subject: [PATCH 091/499] enclose permissions editor logic in two callbacks (undo previous split change) --- .../permissions/userPermissionsEditor.es6 | 119 +++++++++++------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 120bc8b5f2..32b13c803d 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -9,6 +9,7 @@ import stores from 'js/stores'; import actions from 'js/actions'; import bem from 'js/bem'; import classNames from 'classnames'; +import permParser from './permParser'; import { t, notify @@ -28,6 +29,8 @@ class UserPermissionsEditor extends React.Component { usernamesBeingChecked: new Set(), isSubmitPending: false, isEditingUsername: false, + formViewDisabled: false, + submissionsViewDisabled: false, // form user inputs username: '', formView: false, @@ -37,7 +40,7 @@ class UserPermissionsEditor extends React.Component { submissionsViewPartialUsers: [], submissionsAdd: false, submissionsEdit: false, - submissionsValidate: false, + submissionsValidate: false }; this.applyPropsData(); @@ -77,54 +80,62 @@ class UserPermissionsEditor extends React.Component { } } - onFormViewChange(isChecked) { - this.setState({ - formView: isChecked - }); - } - - onFormEditChange(isChecked) { - this.setState({ - formEdit: isChecked - }); - } - - onSubmissionsViewChange(isChecked) { - const newState = { - submissionsView: isChecked - }; + /* + * Single callback for all checkboxes to keep the complex connections logic + * being up to date regardless which one changed + * NOTE: the order of things is important + */ + onCheckboxChange(id, isChecked) { + // apply checked checkbox change to state + const newState = this.state; + newState[id] = isChecked; - if (!isChecked) { - // reset partial inputs + // reset partial inputs when unchecking `submissionsView` + if (newState.submissionsView === false) { newState.submissionsViewPartial = false; newState.submissionsViewPartialUsers = []; } this.setState(newState); - } - onSubmissionsViewPartialChange(isChecked) { - this.setState({ - submissionsViewPartial: isChecked - }); + this.verifyConnectedCheckboxes(); } - onSubmissionsAddChange(isChecked) { - this.setState({ - submissionsAdd: isChecked - }); - } + /* + * Checking some of the checkboxes implies that other are also checked + * and disabled (to avoid users from submitting invalid data) + */ + verifyConnectedCheckboxes() { + const newState = this.state; + + // reset disabling before checks + newState.formViewDisabled = false; + newState.submissionsViewDisabled = false; + + // checking these options implies having `formView` + if ( + newState.formEdit || + newState.submissionsView || + newState.submissionsViewPartial || + newState.submissionsAdd || + newState.submissionsEdit || + newState.submissionsValidate + ) { + newState.formView = true; + newState.formViewDisabled = true; + } - onSubmissionsEditChange(isChecked) { - this.setState({ - submissionsEdit: isChecked - }); - } + // checking these options implies having `submissionsView` + if ( + newState.submissionsEdit || + newState.submissionsValidate + ) { + newState.submissionsView = true; + newState.submissionsViewDisabled = true; + } - onSubmissionsValidateChange(isChecked) { - this.setState({ - submissionsValidate: isChecked - }); + // apply changes of connected checkboxes to state + this.setState(newState); } /** @@ -245,8 +256,20 @@ class UserPermissionsEditor extends React.Component { return; } + const parsedData = permParser.parseFormData({ + username: this.state.username, + formView: this.state.formView, + formEdit: this.state.formEdit, + submissionsView: this.state.submissionsView, + submissionsViewPartial: this.state.submissionsViewPartial, + submissionsViewPartialUsers: this.state.submissionsViewPartialUsers, + submissionsAdd: this.state.submissionsAdd, + submissionsEdit: this.state.submissionsEdit, + submissionsValidate: this.state.submissionsValidate + }); + // TODO: add or patch permission - console.log('submit', this.state); + console.log('submit', this.state, parsedData); // make sure user exists if (this.checkUsernameSync(this.state.username)) { @@ -262,7 +285,7 @@ class UserPermissionsEditor extends React.Component { } render() { - const isNew = this.state.username.length === 0; + const isNew = typeof this.props.username === 'undefined'; const submissionsViewPartialUsersInputProps = { placeholder: t('Add username(s)') @@ -300,13 +323,14 @@ class UserPermissionsEditor extends React.Component {
@@ -315,7 +339,8 @@ class UserPermissionsEditor extends React.Component { )}> @@ -323,7 +348,7 @@ class UserPermissionsEditor extends React.Component {
@@ -341,19 +366,19 @@ class UserPermissionsEditor extends React.Component {
From 37ae1bb01b564a7b8d8ed83dfb0dc25d7d1944ff Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 16 May 2019 22:56:05 +0200 Subject: [PATCH 092/499] define permission constatnt --- jsapp/js/constants.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index e2dab1c521..0d8d7dbc24 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -140,6 +140,7 @@ export const ASSET_TYPES = { }; export default { + PERMISSIONS: PERMISSIONS, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, update_states: update_states, VALIDATION_STATUSES: VALIDATION_STATUSES, From 036e67b713af6df4fb182e2936a760c936790d3e Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 22 May 2019 22:14:39 +0200 Subject: [PATCH 093/499] linter fixes --- jsapp/js/app.es6 | 1 - 1 file changed, 1 deletion(-) diff --git a/jsapp/js/app.es6 b/jsapp/js/app.es6 index a3e7256def..080779d8c0 100644 --- a/jsapp/js/app.es6 +++ b/jsapp/js/app.es6 @@ -58,7 +58,6 @@ import LibrarySearchableList from './lists/library'; import FormsSearchableList from './lists/forms'; import {getZebraLoginUrl} from './config'; - class App extends React.Component { constructor(props) { super(props); From cf7a4188fd92c2d1459f6bc47f75f02887b1c6af Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 24 May 2019 22:12:46 +0200 Subject: [PATCH 094/499] parsing permissions from new endpoint --- jsapp/js/components/permissions/sharingForm.es6 | 3 +++ jsapp/js/components/permissions/userPermissionsEditor.es6 | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/jsapp/js/components/permissions/sharingForm.es6 b/jsapp/js/components/permissions/sharingForm.es6 index a7226038e6..ce44735041 100644 --- a/jsapp/js/components/permissions/sharingForm.es6 +++ b/jsapp/js/components/permissions/sharingForm.es6 @@ -43,6 +43,7 @@ class SharingForm extends React.Component { if (this.props.uid) { actions.resources.loadAsset({id: this.props.uid}); + actions.permissions.getAssetPermissions(this.props.uid); } this.onAllAssetsChange(); @@ -86,6 +87,8 @@ class SharingForm extends React.Component { const asset = data[uid]; if (asset) { + console.debug('onAssetChange', asset); + this.setState({ asset: asset, kind: asset.kind diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 32b13c803d..29171a9262 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -242,6 +242,9 @@ class UserPermissionsEditor extends React.Component { }); } + /** + * Disallows submitting non-ready form + */ isSubmitEnabled() { return ( !this.state.isSubmitPending && @@ -279,7 +282,7 @@ class UserPermissionsEditor extends React.Component { uid: this.props.uid, kind: this.props.kind, objectUrl: this.props.objectUrl, - role: 'view' + role: 'view_submissions' }); } } From 2fc0eddbb29df597034fad8d264a60d3bbfcd2e3 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 24 May 2019 23:13:56 +0200 Subject: [PATCH 095/499] display permissions from new endpoint --- jsapp/js/components/permissions/sharingForm.es6 | 5 ++--- jsapp/js/components/permissions/userPermissionsEditor.es6 | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/jsapp/js/components/permissions/sharingForm.es6 b/jsapp/js/components/permissions/sharingForm.es6 index ce44735041..6c45da0ddd 100644 --- a/jsapp/js/components/permissions/sharingForm.es6 +++ b/jsapp/js/components/permissions/sharingForm.es6 @@ -43,7 +43,6 @@ class SharingForm extends React.Component { if (this.props.uid) { actions.resources.loadAsset({id: this.props.uid}); - actions.permissions.getAssetPermissions(this.props.uid); } this.onAllAssetsChange(); @@ -145,7 +144,7 @@ class SharingForm extends React.Component { } let uid = this.state.asset.uid, - kind = this.state.asset.kind, + assetKind = this.state.asset.kind, asset_type = this.state.asset.asset_type, objectUrl = this.state.asset.url; @@ -214,7 +213,7 @@ class SharingForm extends React.Component { {/* public sharing settings */} - { kind !== 'collection' && asset_type === 'survey' && + { assetKind !== 'collection' && asset_type === 'survey' && diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 29171a9262..9610cb920a 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -279,8 +279,8 @@ class UserPermissionsEditor extends React.Component { this.setState({isSubmitPending: true}); actions.permissions.assignPerm({ username: this.state.username, - uid: this.props.uid, - kind: this.props.kind, + uid: this.props.assetUid, + kind: this.props.assetKind, objectUrl: this.props.objectUrl, role: 'view_submissions' }); From 6bff7eb5a31edbaaf2679a0aff61f739eaa4cdf9 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sat, 25 May 2019 10:09:05 +0200 Subject: [PATCH 096/499] first test for permParser --- jsapp/js/constants.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index 0d8d7dbc24..6941f6dced 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -140,7 +140,7 @@ export const ASSET_TYPES = { }; export default { - PERMISSIONS: PERMISSIONS, + PERMISSIONS_CODENAMES: PERMISSIONS_CODENAMES, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, update_states: update_states, VALIDATION_STATUSES: VALIDATION_STATUSES, From 4832a8b0eccf2f2a18091a18098820de48b75546 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sat, 25 May 2019 19:10:14 +0200 Subject: [PATCH 097/499] move anonUsername to constants (where it should be) --- jsapp/js/constants.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index 6941f6dced..ffd298a9ea 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -140,6 +140,7 @@ export const ASSET_TYPES = { }; export default { + ANON_USERNAME: ANON_USERNAME, PERMISSIONS_CODENAMES: PERMISSIONS_CODENAMES, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, update_states: update_states, From 67ee362f2de94d26e5fa2b9712d29fbec9bfa26e Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sat, 25 May 2019 20:53:52 +0200 Subject: [PATCH 098/499] add contradictory-like rules to userPermissionsEditor checkboxes --- .../permissions/userPermissionsEditor.es6 | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 9610cb920a..0a9dae374d 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -29,24 +29,27 @@ class UserPermissionsEditor extends React.Component { usernamesBeingChecked: new Set(), isSubmitPending: false, isEditingUsername: false, - formViewDisabled: false, - submissionsViewDisabled: false, // form user inputs username: '', formView: false, + formViewDisabled: false, formEdit: false, submissionsView: false, + submissionsViewDisabled: false, submissionsViewPartial: false, + submissionsViewPartialDisabled: false, submissionsViewPartialUsers: [], submissionsAdd: false, submissionsEdit: false, - submissionsValidate: false + submissionsEditDisabled: false, + submissionsValidate: false, + submissionsValidateDisabled: false }; this.applyPropsData(); } - /* + /** * Fills up form with provided user name and permissions (if applicable) */ applyPropsData() { @@ -80,7 +83,7 @@ class UserPermissionsEditor extends React.Component { } } - /* + /** * Single callback for all checkboxes to keep the complex connections logic * being up to date regardless which one changed * NOTE: the order of things is important @@ -98,21 +101,28 @@ class UserPermissionsEditor extends React.Component { this.setState(newState); - this.verifyConnectedCheckboxes(); + this.applyValidityRules(); } - /* + /** + * Helps to avoid users submitting invalid data. + * * Checking some of the checkboxes implies that other are also checked - * and disabled (to avoid users from submitting invalid data) + * and can't be unchecked. + * + * Checking some of the checkboxes implies that other can't be checked. */ - verifyConnectedCheckboxes() { + applyValidityRules() { const newState = this.state; // reset disabling before checks newState.formViewDisabled = false; newState.submissionsViewDisabled = false; + newState.submissionsViewPartialDisabled = false; + newState.submissionsEditDisabled = false; + newState.submissionsValidateDisabled = false; - // checking these options implies having `formView` + // checking these options implies having `formView` checked if ( newState.formEdit || newState.submissionsView || @@ -125,7 +135,7 @@ class UserPermissionsEditor extends React.Component { newState.formViewDisabled = true; } - // checking these options implies having `submissionsView` + // checking these options implies having `submissionsView` checked if ( newState.submissionsEdit || newState.submissionsValidate @@ -134,6 +144,24 @@ class UserPermissionsEditor extends React.Component { newState.submissionsViewDisabled = true; } + // checking `submissionsViewPartial` disallows checking these + if (newState.submissionsViewPartial) { + newState.submissionsEdit = false; + newState.submissionsEditDisabled = true; + newState.submissionsValidate = false; + newState.submissionsValidateDisabled = true; + } + + // checking these disallows checking `submissionsViewPartial` + if ( + newState.submissionsEdit || + newState.submissionsValidate + ) { + newState.submissionsViewPartial = false; + newState.submissionsViewPartialDisabled = true; + newState.submissionsViewPartialUsers = []; + } + // apply changes of connected checkboxes to state this.setState(newState); } @@ -351,6 +379,7 @@ class UserPermissionsEditor extends React.Component {
@@ -375,12 +404,14 @@ class UserPermissionsEditor extends React.Component { From 972f84f807b16ee1ddd8348499791c77d9429062 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sat, 25 May 2019 20:54:11 +0200 Subject: [PATCH 099/499] add comments to permConfig --- jsapp/js/components/permissions/permConfig.es6 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/jsapp/js/components/permissions/permConfig.es6 b/jsapp/js/components/permissions/permConfig.es6 index d480dd7273..dae209d3c5 100644 --- a/jsapp/js/components/permissions/permConfig.es6 +++ b/jsapp/js/components/permissions/permConfig.es6 @@ -53,6 +53,7 @@ const permConfig = Reflux.createStore({ this.trigger(changed); } }, + onGetConfigCompleted(response) { this.setState({permissions: response.results}); }, @@ -93,6 +94,10 @@ const permConfig = Reflux.createStore({ throw new Error(t('Permission config is not ready or failed to initialize!')); } }, + + /** + * Returns a list of available permissions for given asset type. + */ getAvailablePermissions(assetType) { if (assetType === 'survey') { return [ From eb5f130e094bd7d723a65874672f8602f59904f5 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sat, 25 May 2019 20:55:42 +0200 Subject: [PATCH 100/499] move rootUrl to constants --- jsapp/js/constants.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index ffd298a9ea..0e51b333b7 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -140,6 +140,7 @@ export const ASSET_TYPES = { }; export default { + ROOT_URL: ROOT_URL, ANON_USERNAME: ANON_USERNAME, PERMISSIONS_CODENAMES: PERMISSIONS_CODENAMES, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, From a7fec19ab6ab9e2dd89d1410f366035c82c45f94 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sat, 25 May 2019 21:18:55 +0200 Subject: [PATCH 101/499] permParser builds array of PermObjs with failing (for now) tests --- .../permissions/permParser.tests.es6 | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index 2eee46b17a..aea999d0d5 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -267,4 +267,60 @@ describe('permParser', () => { ]); }); }); + + describe('parseFormData', () => { + it('should exclude all implied permissions as they are not needed', () => { + const parsed = permParser.parseFormData({ + username: 'leszek', + formView: true, + formEdit: true, + submissionsView: true, + submissionsViewPartial: false, + submissionsViewPartialUsers: [], + submissionsAdd: false, + submissionsEdit: false, + submissionsValidate: true + }); + + chai.expect(parsed).to.deep.equal([ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/change_asset/' + }, + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/validate_submissions/' + } + ]); + }); + + it('should create different data for partial submissions permission', () => { + const parsed = permParser.parseFormData({ + username: 'leszek', + formView: true, + formEdit: false, + submissionsView: true, + submissionsViewPartial: true, + submissionsViewPartialUsers: ['john', 'oliver', 'eric'], + submissionsAdd: false, + submissionsEdit: false, + submissionsValidate: false + }); + + chai.expect(parsed).to.deep.equal([ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/partial_submissions/', + partial_permissions: [ + { + url: '/api/v2/permissions/view_submissions/', + filters: [ + {'_submitted_by': {'$in': ['john', 'oliver', 'eric']}} + ] + } + ] + } + ]); + }); + }); }); From a73440f0bff5d398f71a87fd7934cccb51697e66 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Mon, 27 May 2019 22:15:28 +0200 Subject: [PATCH 102/499] chained ajax call for assigning permissions --- .../permissions/userPermissionsEditor.es6 | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 0a9dae374d..86c36de3a4 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -16,8 +16,8 @@ import { } from 'js/utils'; /** - * Displays a form for either giving a new user some permissions, - * or for editing existing user permissions + * Displays a form either for giving a new user some permissions, + * or for editing existing user permissions. */ class UserPermissionsEditor extends React.Component { constructor(props) { @@ -56,7 +56,8 @@ class UserPermissionsEditor extends React.Component { // TODO 1: set permissions from props if given // TODO 2: set mode based on props (i.e. editing existing permissions vs giving new) - console.log('applyPropsData', this.props); + console.debug('applyPropsData', this.props); + console.debug('TODO: here or some other place: hide permissions that are not for given asset kind'); if (this.props.username) { this.state.username = this.props.username; @@ -64,19 +65,19 @@ class UserPermissionsEditor extends React.Component { } componentDidMount() { - this.listenTo(actions.permissions.assignPerm.completed, this.onAssignPermCompleted); - this.listenTo(actions.permissions.assignPerm.failed, this.onAssignPermFailed); + this.listenTo(actions.permissions.setAssetPermissions.completed, this.onSetAssetPermissionsCompleted); + this.listenTo(actions.permissions.setAssetPermissions.failed, this.onSetAssetPermissionsFailed); this.listenTo(stores.userExists, this.onUserExistsStoreChange); } - onAssignPermCompleted() { + onSetAssetPermissionsCompleted() { this.setState({isSubmitPending: false}); if (typeof this.props.onSubmitEnd === 'function') { this.props.onSubmitEnd(true); } } - onAssignPermFailed() { + onSetAssetPermissionsFailed() { this.setState({isSubmitPending: false}); if (typeof this.props.onSubmitEnd === 'function') { this.props.onSubmitEnd(false); @@ -85,8 +86,7 @@ class UserPermissionsEditor extends React.Component { /** * Single callback for all checkboxes to keep the complex connections logic - * being up to date regardless which one changed - * NOTE: the order of things is important + * being up to date regardless which one changed. */ onCheckboxChange(id, isChecked) { // apply checked checkbox change to state @@ -101,6 +101,7 @@ class UserPermissionsEditor extends React.Component { this.setState(newState); + // needs to be called last this.applyValidityRules(); } @@ -144,7 +145,7 @@ class UserPermissionsEditor extends React.Component { newState.submissionsViewDisabled = true; } - // checking `submissionsViewPartial` disallows checking these + // checking `submissionsViewPartial` disallows checking two other options if (newState.submissionsViewPartial) { newState.submissionsEdit = false; newState.submissionsEditDisabled = true; @@ -152,7 +153,7 @@ class UserPermissionsEditor extends React.Component { newState.submissionsValidateDisabled = true; } - // checking these disallows checking `submissionsViewPartial` + // checking these options disallows checking `submissionsViewPartial` if ( newState.submissionsEdit || newState.submissionsValidate @@ -167,8 +168,8 @@ class UserPermissionsEditor extends React.Component { } /** - * we need it just to update the input, - * real work is handled by onUsernameChangeEnd + * We need it just to update the input, + * the real work is handled by onUsernameChangeEnd. */ onUsernameChange(username) { this.setState({ @@ -177,6 +178,9 @@ class UserPermissionsEditor extends React.Component { }); } + /** + * Checks if username exist on the Backend and clears input if doesn't. + */ onUsernameChangeEnd() { this.setState({isEditingUsername: false}); @@ -193,6 +197,9 @@ class UserPermissionsEditor extends React.Component { } } + /** + * Enables Enter key on username input. + */ onUsernameKeyPress(key, evt) { if (key === 'Enter') { evt.currentTarget.blur(); @@ -200,6 +207,9 @@ class UserPermissionsEditor extends React.Component { } } + /** + * Handles TagsInput change event and blocks adding nonexistent usernames. + */ onSubmissionsViewPartialUsersChange(allUsers) { const submissionsViewPartialUsers = []; @@ -243,7 +253,7 @@ class UserPermissionsEditor extends React.Component { } /** - * Remove nonexistent usernames from tagsinput array + * Remove nonexistent usernames from TagsInput list and from username input. */ onUserExistsStoreChange(result) { // check partial view users @@ -271,7 +281,7 @@ class UserPermissionsEditor extends React.Component { } /** - * Disallows submitting non-ready form + * Blocks submitting non-ready form. */ isSubmitEnabled() { return ( @@ -300,18 +310,15 @@ class UserPermissionsEditor extends React.Component { }); // TODO: add or patch permission - console.log('submit', this.state, parsedData); + console.debug('submit', parsedData); // make sure user exists if (this.checkUsernameSync(this.state.username)) { this.setState({isSubmitPending: true}); - actions.permissions.assignPerm({ - username: this.state.username, - uid: this.props.assetUid, - kind: this.props.assetKind, - objectUrl: this.props.objectUrl, - role: 'view_submissions' - }); + actions.permissions.setAssetPermissions( + this.props.assetUid, + parsedData + ); } } @@ -339,7 +346,7 @@ class UserPermissionsEditor extends React.Component { onSubmit={this.submit} > {isNew && - // don't display username editor for editing existing user + // don't display username editor when editing existing user
Date: Tue, 28 May 2019 12:57:28 +0200 Subject: [PATCH 103/499] building form data from permissions list and applying to form --- .../permissions/userPermissionsEditor.es6 | 73 ++++++++++--------- .../scss/components/_kobo.sharing-modal.scss | 4 + 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 86c36de3a4..eb128f5657 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -11,6 +11,7 @@ import bem from 'js/bem'; import classNames from 'classnames'; import permParser from './permParser'; import { + assign, t, notify } from 'js/utils'; @@ -56,9 +57,11 @@ class UserPermissionsEditor extends React.Component { // TODO 1: set permissions from props if given // TODO 2: set mode based on props (i.e. editing existing permissions vs giving new) - console.debug('applyPropsData', this.props); console.debug('TODO: here or some other place: hide permissions that are not for given asset kind'); + const formData = permParser.buildFormData(this.props.permissions); + this.state = this.applyValidityRules(assign(this.state, formData)); + if (this.props.username) { this.state.username = this.props.username; } @@ -99,10 +102,8 @@ class UserPermissionsEditor extends React.Component { newState.submissionsViewPartialUsers = []; } - this.setState(newState); - // needs to be called last - this.applyValidityRules(); + this.setState(this.applyValidityRules(newState)); } /** @@ -112,59 +113,59 @@ class UserPermissionsEditor extends React.Component { * and can't be unchecked. * * Checking some of the checkboxes implies that other can't be checked. + * + * @param {Object} state + * @returns {Object} updated state */ - applyValidityRules() { - const newState = this.state; - + applyValidityRules(stateObj) { // reset disabling before checks - newState.formViewDisabled = false; - newState.submissionsViewDisabled = false; - newState.submissionsViewPartialDisabled = false; - newState.submissionsEditDisabled = false; - newState.submissionsValidateDisabled = false; + stateObj.formViewDisabled = false; + stateObj.submissionsViewDisabled = false; + stateObj.submissionsViewPartialDisabled = false; + stateObj.submissionsEditDisabled = false; + stateObj.submissionsValidateDisabled = false; // checking these options implies having `formView` checked if ( - newState.formEdit || - newState.submissionsView || - newState.submissionsViewPartial || - newState.submissionsAdd || - newState.submissionsEdit || - newState.submissionsValidate + stateObj.formEdit || + stateObj.submissionsView || + stateObj.submissionsViewPartial || + stateObj.submissionsAdd || + stateObj.submissionsEdit || + stateObj.submissionsValidate ) { - newState.formView = true; - newState.formViewDisabled = true; + stateObj.formView = true; + stateObj.formViewDisabled = true; } // checking these options implies having `submissionsView` checked if ( - newState.submissionsEdit || - newState.submissionsValidate + stateObj.submissionsEdit || + stateObj.submissionsValidate ) { - newState.submissionsView = true; - newState.submissionsViewDisabled = true; + stateObj.submissionsView = true; + stateObj.submissionsViewDisabled = true; } // checking `submissionsViewPartial` disallows checking two other options - if (newState.submissionsViewPartial) { - newState.submissionsEdit = false; - newState.submissionsEditDisabled = true; - newState.submissionsValidate = false; - newState.submissionsValidateDisabled = true; + if (stateObj.submissionsViewPartial) { + stateObj.submissionsEdit = false; + stateObj.submissionsEditDisabled = true; + stateObj.submissionsValidate = false; + stateObj.submissionsValidateDisabled = true; } // checking these options disallows checking `submissionsViewPartial` if ( - newState.submissionsEdit || - newState.submissionsValidate + stateObj.submissionsEdit || + stateObj.submissionsValidate ) { - newState.submissionsViewPartial = false; - newState.submissionsViewPartialDisabled = true; - newState.submissionsViewPartialUsers = []; + stateObj.submissionsViewPartial = false; + stateObj.submissionsViewPartialDisabled = true; + stateObj.submissionsViewPartialUsers = []; } - // apply changes of connected checkboxes to state - this.setState(newState); + return stateObj; } /** diff --git a/jsapp/scss/components/_kobo.sharing-modal.scss b/jsapp/scss/components/_kobo.sharing-modal.scss index 129322bf7b..24dc74dad8 100644 --- a/jsapp/scss/components/_kobo.sharing-modal.scss +++ b/jsapp/scss/components/_kobo.sharing-modal.scss @@ -88,6 +88,10 @@ $s-gray-row-spacing: 10px; .user-row__perms { min-width: 160px; text-align: right; +<<<<<<< HEAD +======= + line-height: 2.2em; +>>>>>>> building form data from permissions list and applying to form padding: 0 10px 0 15px; .user-row__perm { From fe03445b48a59f3bffe789f464ceb01c87c9f2a8 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 28 May 2019 15:47:00 +0200 Subject: [PATCH 104/499] render permissions list better --- .../permissions/permParser.tests.es6 | 32 +++++++++++++++++++ .../permissions/userPermissionsEditor.es6 | 6 ++-- .../scss/components/_kobo.sharing-modal.scss | 4 --- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index aea999d0d5..5403451748 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -268,6 +268,38 @@ describe('permParser', () => { }); }); + describe('buildFormData', () => { + it('should check proper options', () => { + const parsed = permParser.parseBackendData( + endpoints.assetWithMulti.results, + endpoints.assetWithMulti.results[0].user + ); + + const built = permParser.buildFormData(parsed[2].permissions); + + chai.expect(built).to.deep.equal({ + formView: true, + submissionsView: true + }); + }); + + it('should handle partial permissions', () => { + const parsed = permParser.parseBackendData( + endpoints.assetWithPartial.results, + endpoints.assetWithPartial.results[0].user + ); + + const built = permParser.buildFormData(parsed[1].permissions); + + chai.expect(built).to.deep.equal({ + formView: true, + submissionsView: true, + submissionsViewPartial: true, + submissionsViewPartialUsers: ['john', 'oliver'] + }); + }); + }); + describe('parseFormData', () => { it('should exclude all implied permissions as they are not needed', () => { const parsed = permParser.parseFormData({ diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index eb128f5657..331cacbc5c 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -59,8 +59,10 @@ class UserPermissionsEditor extends React.Component { console.debug('TODO: here or some other place: hide permissions that are not for given asset kind'); - const formData = permParser.buildFormData(this.props.permissions); - this.state = this.applyValidityRules(assign(this.state, formData)); + if (this.props.permissions) { + const formData = permParser.buildFormData(this.props.permissions); + this.state = this.applyValidityRules(assign(this.state, formData)); + } if (this.props.username) { this.state.username = this.props.username; diff --git a/jsapp/scss/components/_kobo.sharing-modal.scss b/jsapp/scss/components/_kobo.sharing-modal.scss index 24dc74dad8..129322bf7b 100644 --- a/jsapp/scss/components/_kobo.sharing-modal.scss +++ b/jsapp/scss/components/_kobo.sharing-modal.scss @@ -88,10 +88,6 @@ $s-gray-row-spacing: 10px; .user-row__perms { min-width: 160px; text-align: right; -<<<<<<< HEAD -======= - line-height: 2.2em; ->>>>>>> building form data from permissions list and applying to form padding: 0 10px 0 15px; .user-row__perm { From 4431b89349c79bcef9043258023c68aad04ef055 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 28 May 2019 16:42:55 +0200 Subject: [PATCH 105/499] cleanups --- jsapp/js/components/permissions/userPermissionsEditor.es6 | 6 ------ 1 file changed, 6 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 index 331cacbc5c..6ec34f9a12 100644 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ b/jsapp/js/components/permissions/userPermissionsEditor.es6 @@ -54,9 +54,6 @@ class UserPermissionsEditor extends React.Component { * Fills up form with provided user name and permissions (if applicable) */ applyPropsData() { - // TODO 1: set permissions from props if given - // TODO 2: set mode based on props (i.e. editing existing permissions vs giving new) - console.debug('TODO: here or some other place: hide permissions that are not for given asset kind'); if (this.props.permissions) { @@ -312,9 +309,6 @@ class UserPermissionsEditor extends React.Component { submissionsValidate: this.state.submissionsValidate }); - // TODO: add or patch permission - console.debug('submit', parsedData); - // make sure user exists if (this.checkUsernameSync(this.state.username)) { this.setState({isSubmitPending: true}); From 2bad9d6d1fbd28fc23e7de7c81d50ab40147b51c Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 28 May 2019 21:31:51 +0200 Subject: [PATCH 106/499] rename permissions editor before introducing one for collections --- .../permissions/permParser.tests.es6 | 2 +- .../js/components/permissions/sharingForm.es6 | 2 - .../permissions/userPermissionsEditor.es6 | 439 ------------------ jsapp/js/constants.es6 | 6 + 4 files changed, 7 insertions(+), 442 deletions(-) delete mode 100644 jsapp/js/components/permissions/userPermissionsEditor.es6 diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index 5403451748..a34288b190 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -326,7 +326,7 @@ describe('permParser', () => { ]); }); - it('should create different data for partial submissions permission', () => { + it('should add partial_permissions property for partial submissions permission', () => { const parsed = permParser.parseFormData({ username: 'leszek', formView: true, diff --git a/jsapp/js/components/permissions/sharingForm.es6 b/jsapp/js/components/permissions/sharingForm.es6 index 6c45da0ddd..387d0e563e 100644 --- a/jsapp/js/components/permissions/sharingForm.es6 +++ b/jsapp/js/components/permissions/sharingForm.es6 @@ -86,8 +86,6 @@ class SharingForm extends React.Component { const asset = data[uid]; if (asset) { - console.debug('onAssetChange', asset); - this.setState({ asset: asset, kind: asset.kind diff --git a/jsapp/js/components/permissions/userPermissionsEditor.es6 b/jsapp/js/components/permissions/userPermissionsEditor.es6 deleted file mode 100644 index 6ec34f9a12..0000000000 --- a/jsapp/js/components/permissions/userPermissionsEditor.es6 +++ /dev/null @@ -1,439 +0,0 @@ -import React from 'react'; -import reactMixin from 'react-mixin'; -import autoBind from 'react-autobind'; -import Reflux from 'reflux'; -import TagsInput from 'react-tagsinput'; -import Checkbox from 'js/components/checkbox'; -import TextBox from 'js/components/textBox'; -import stores from 'js/stores'; -import actions from 'js/actions'; -import bem from 'js/bem'; -import classNames from 'classnames'; -import permParser from './permParser'; -import { - assign, - t, - notify -} from 'js/utils'; - -/** - * Displays a form either for giving a new user some permissions, - * or for editing existing user permissions. - */ -class UserPermissionsEditor extends React.Component { - constructor(props) { - super(props); - autoBind(this); - - this.state = { - // inner workings - usernamesBeingChecked: new Set(), - isSubmitPending: false, - isEditingUsername: false, - // form user inputs - username: '', - formView: false, - formViewDisabled: false, - formEdit: false, - submissionsView: false, - submissionsViewDisabled: false, - submissionsViewPartial: false, - submissionsViewPartialDisabled: false, - submissionsViewPartialUsers: [], - submissionsAdd: false, - submissionsEdit: false, - submissionsEditDisabled: false, - submissionsValidate: false, - submissionsValidateDisabled: false - }; - - this.applyPropsData(); - } - - /** - * Fills up form with provided user name and permissions (if applicable) - */ - applyPropsData() { - console.debug('TODO: here or some other place: hide permissions that are not for given asset kind'); - - if (this.props.permissions) { - const formData = permParser.buildFormData(this.props.permissions); - this.state = this.applyValidityRules(assign(this.state, formData)); - } - - if (this.props.username) { - this.state.username = this.props.username; - } - } - - componentDidMount() { - this.listenTo(actions.permissions.setAssetPermissions.completed, this.onSetAssetPermissionsCompleted); - this.listenTo(actions.permissions.setAssetPermissions.failed, this.onSetAssetPermissionsFailed); - this.listenTo(stores.userExists, this.onUserExistsStoreChange); - } - - onSetAssetPermissionsCompleted() { - this.setState({isSubmitPending: false}); - if (typeof this.props.onSubmitEnd === 'function') { - this.props.onSubmitEnd(true); - } - } - - onSetAssetPermissionsFailed() { - this.setState({isSubmitPending: false}); - if (typeof this.props.onSubmitEnd === 'function') { - this.props.onSubmitEnd(false); - } - } - - /** - * Single callback for all checkboxes to keep the complex connections logic - * being up to date regardless which one changed. - */ - onCheckboxChange(id, isChecked) { - // apply checked checkbox change to state - const newState = this.state; - newState[id] = isChecked; - - // reset partial inputs when unchecking `submissionsView` - if (newState.submissionsView === false) { - newState.submissionsViewPartial = false; - newState.submissionsViewPartialUsers = []; - } - - // needs to be called last - this.setState(this.applyValidityRules(newState)); - } - - /** - * Helps to avoid users submitting invalid data. - * - * Checking some of the checkboxes implies that other are also checked - * and can't be unchecked. - * - * Checking some of the checkboxes implies that other can't be checked. - * - * @param {Object} state - * @returns {Object} updated state - */ - applyValidityRules(stateObj) { - // reset disabling before checks - stateObj.formViewDisabled = false; - stateObj.submissionsViewDisabled = false; - stateObj.submissionsViewPartialDisabled = false; - stateObj.submissionsEditDisabled = false; - stateObj.submissionsValidateDisabled = false; - - // checking these options implies having `formView` checked - if ( - stateObj.formEdit || - stateObj.submissionsView || - stateObj.submissionsViewPartial || - stateObj.submissionsAdd || - stateObj.submissionsEdit || - stateObj.submissionsValidate - ) { - stateObj.formView = true; - stateObj.formViewDisabled = true; - } - - // checking these options implies having `submissionsView` checked - if ( - stateObj.submissionsEdit || - stateObj.submissionsValidate - ) { - stateObj.submissionsView = true; - stateObj.submissionsViewDisabled = true; - } - - // checking `submissionsViewPartial` disallows checking two other options - if (stateObj.submissionsViewPartial) { - stateObj.submissionsEdit = false; - stateObj.submissionsEditDisabled = true; - stateObj.submissionsValidate = false; - stateObj.submissionsValidateDisabled = true; - } - - // checking these options disallows checking `submissionsViewPartial` - if ( - stateObj.submissionsEdit || - stateObj.submissionsValidate - ) { - stateObj.submissionsViewPartial = false; - stateObj.submissionsViewPartialDisabled = true; - stateObj.submissionsViewPartialUsers = []; - } - - return stateObj; - } - - /** - * We need it just to update the input, - * the real work is handled by onUsernameChangeEnd. - */ - onUsernameChange(username) { - this.setState({ - username: username, - isEditingUsername: true - }); - } - - /** - * Checks if username exist on the Backend and clears input if doesn't. - */ - onUsernameChangeEnd() { - this.setState({isEditingUsername: false}); - - if (this.state.username === '') { - return; - } - - const userCheck = this.checkUsernameSync(this.state.username); - if (userCheck === undefined) { - this.checkUsernameAsync(this.state.username); - } else if (userCheck === false) { - this.notifyUnknownUser(this.state.username); - this.setState({username: ''}); - } - } - - /** - * Enables Enter key on username input. - */ - onUsernameKeyPress(key, evt) { - if (key === 'Enter') { - evt.currentTarget.blur(); - evt.preventDefault(); // prevent submitting form - } - } - - /** - * Handles TagsInput change event and blocks adding nonexistent usernames. - */ - onSubmissionsViewPartialUsersChange(allUsers) { - const submissionsViewPartialUsers = []; - - allUsers.forEach((username) => { - const userCheck = this.checkUsernameSync(username); - if (userCheck === true) { - submissionsViewPartialUsers.push(username); - } else if (userCheck === undefined) { - // we add unknown usernames for now and will check and possibly remove - // with checkUsernameAsync - submissionsViewPartialUsers.push(username); - this.checkUsernameAsync(username); - } else { - this.notifyUnknownUser(username); - } - }); - - this.setState({submissionsViewPartialUsers: submissionsViewPartialUsers}); - } - - /** - * This function returns either boolean (for known username) or undefined - * for usernames that weren't checked before - */ - checkUsernameSync(username) { - return stores.userExists.checkUsername(username); - } - - /** - * This function calls API and relies on onUserExistsStoreChange callback - */ - checkUsernameAsync(username) { - const usernamesBeingChecked = this.state.usernamesBeingChecked; - usernamesBeingChecked.add(username); - this.setState({usernamesBeingChecked: usernamesBeingChecked}); - actions.misc.checkUsername(username); - } - - notifyUnknownUser(username) { - notify(`${t('User not found:')} ${username}`, 'warning'); - } - - /** - * Remove nonexistent usernames from TagsInput list and from username input. - */ - onUserExistsStoreChange(result) { - // check partial view users - const submissionsViewPartialUsers = this.state.submissionsViewPartialUsers; - submissionsViewPartialUsers.forEach((username) => { - if (result[username] === false) { - submissionsViewPartialUsers.pop(submissionsViewPartialUsers.indexOf(username)); - this.notifyUnknownUser(username); - } - }); - this.setState({submissionsViewPartialUsers: submissionsViewPartialUsers}); - - // check username - if (result[this.state.username] === false) { - this.notifyUnknownUser(this.state.username); - this.setState({username: ''}); - } - - // clean usernamesBeingChecked array - Object.keys(result).forEach((username) => { - const usernamesBeingChecked = this.state.usernamesBeingChecked; - usernamesBeingChecked.delete(username); - this.setState({usernamesBeingChecked: usernamesBeingChecked}); - }); - } - - /** - * Blocks submitting non-ready form. - */ - isSubmitEnabled() { - return ( - !this.state.isSubmitPending && - !this.state.isEditingUsername && - this.state.username.length > 0 && - this.state.usernamesBeingChecked.size === 0 - ); - } - - submit() { - if (!this.isSubmitEnabled()) { - return; - } - - const parsedData = permParser.parseFormData({ - username: this.state.username, - formView: this.state.formView, - formEdit: this.state.formEdit, - submissionsView: this.state.submissionsView, - submissionsViewPartial: this.state.submissionsViewPartial, - submissionsViewPartialUsers: this.state.submissionsViewPartialUsers, - submissionsAdd: this.state.submissionsAdd, - submissionsEdit: this.state.submissionsEdit, - submissionsValidate: this.state.submissionsValidate - }); - - // make sure user exists - if (this.checkUsernameSync(this.state.username)) { - this.setState({isSubmitPending: true}); - actions.permissions.setAssetPermissions( - this.props.assetUid, - parsedData - ); - } - } - - render() { - const isNew = typeof this.props.username === 'undefined'; - - const submissionsViewPartialUsersInputProps = { - placeholder: t('Add username(s)') - }; - - const formModifiers = []; - if (this.state.isSubmitPending) { - formModifiers.push('pending'); - } - - const formClassNames = classNames( - 'user-permissions-editor', - isNew ? 'user-permissions-editor--new' : '' - ); - - return ( - - {isNew && - // don't display username editor when editing existing user -
- -
- } - -
- - - - -
- - - {this.state.submissionsView === true && -
- - - {this.state.submissionsViewPartial === true && - - } -
- } -
- - - - - - -
- -
- - {isNew ? t('Grant permissions') : t('Update permissions')} - -
-
- ); - } -} -reactMixin(UserPermissionsEditor.prototype, Reflux.ListenerMixin); - -export default UserPermissionsEditor; diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index 0e51b333b7..aee34c0bd8 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -139,6 +139,12 @@ export const ASSET_TYPES = { } }; +const ASSET_KINDS = new Map(); +new Set([ + 'asset', + 'collection' +]).forEach((kind) => {ASSET_KINDS.set(kind, kind);}); + export default { ROOT_URL: ROOT_URL, ANON_USERNAME: ANON_USERNAME, From e687fc63f766f4c7c467a8ecc49a7138c7b28ecf Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 29 May 2019 10:28:37 +0200 Subject: [PATCH 107/499] introduce new editor for collection permissions --- jsapp/js/actions/permissions.es6 | 4 ++++ jsapp/js/components/permissions/permParser.es6 | 2 +- jsapp/js/components/permissions/sharingForm.es6 | 4 ++-- jsapp/js/components/permissions/userPermissionRow.es6 | 8 ++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/jsapp/js/actions/permissions.es6 b/jsapp/js/actions/permissions.es6 index e153b7b371..ff891e3956 100644 --- a/jsapp/js/actions/permissions.es6 +++ b/jsapp/js/actions/permissions.es6 @@ -119,10 +119,14 @@ permissionsActions.removeAssetPermission.listen((assetUid, perm) => { }); /** +<<<<<<< HEAD * For removing single permission * * @param {string} uid * @param {string} perm - permission url +======= +Old actions +>>>>>>> introduce new editor for collection permissions */ permissionsActions.removeCollectionPermission.listen((uid, perm) => { dataInterface.removePermission(perm) diff --git a/jsapp/js/components/permissions/permParser.es6 b/jsapp/js/components/permissions/permParser.es6 index 4e8d02ac48..1f374f96d4 100644 --- a/jsapp/js/components/permissions/permParser.es6 +++ b/jsapp/js/components/permissions/permParser.es6 @@ -259,7 +259,7 @@ function parseBackendData(data, ownerUrl, includeAnon = false) { * @param {Object} data - OLD permissions array. * @param {string} ownerUrl - Asset owner url (used as identifier). * - * @returns {UserWithPerms[]} An list of users with all their permissions. + * @returns {UserWithPerms[]} An ordered list of users with all their permissions. */ function parseOldBackendData(data, ownerUrl) { const output = []; diff --git a/jsapp/js/components/permissions/sharingForm.es6 b/jsapp/js/components/permissions/sharingForm.es6 index 387d0e563e..a7226038e6 100644 --- a/jsapp/js/components/permissions/sharingForm.es6 +++ b/jsapp/js/components/permissions/sharingForm.es6 @@ -142,7 +142,7 @@ class SharingForm extends React.Component { } let uid = this.state.asset.uid, - assetKind = this.state.asset.kind, + kind = this.state.asset.kind, asset_type = this.state.asset.asset_type, objectUrl = this.state.asset.url; @@ -211,7 +211,7 @@ class SharingForm extends React.Component { {/* public sharing settings */} - { assetKind !== 'collection' && asset_type === 'survey' && + { kind !== 'collection' && asset_type === 'survey' && diff --git a/jsapp/js/components/permissions/userPermissionRow.es6 b/jsapp/js/components/permissions/userPermissionRow.es6 index f5dd38aa34..95cd88dfa0 100644 --- a/jsapp/js/components/permissions/userPermissionRow.es6 +++ b/jsapp/js/components/permissions/userPermissionRow.es6 @@ -44,6 +44,14 @@ class UserPermissionRow extends React.Component { removePermissions() { const dialog = alertify.dialog('confirm'); + + let okCallback; + if (this.props.kind === ASSET_KINDS.get('asset')) { + okCallback = this.removeAssetPermissions; + } else if (this.props.kind === ASSET_KINDS.get('collection')) { + okCallback = this.removeCollectionPermissions; + } + const opts = { title: t('Remove permissions?'), message: t('This action will remove all permissions for user ##username##').replace('##username##', `${this.props.user.name}`), From 465fde720e76d1a74885abaf016b84cd51ad073c Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 29 May 2019 10:56:30 +0200 Subject: [PATCH 108/499] clearer name --- jsapp/js/components/permissions/permParser.es6 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jsapp/js/components/permissions/permParser.es6 b/jsapp/js/components/permissions/permParser.es6 index 1f374f96d4..d11cd0cc1d 100644 --- a/jsapp/js/components/permissions/permParser.es6 +++ b/jsapp/js/components/permissions/permParser.es6 @@ -327,6 +327,9 @@ export const permParser = { buildFormData: buildFormData, parseBackendData: parseBackendData, parseOldBackendData: parseOldBackendData, +<<<<<<< HEAD parseUserWithPermsList: parseUserWithPermsList, +======= +>>>>>>> clearer name sortParseBackendOutput: sortParseBackendOutput // for testing purposes }; From cf460ee44004c727004d74d77d98eae7e8435d56 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 29 May 2019 22:12:44 +0200 Subject: [PATCH 109/499] fix test --- jsapp/js/components/permissions/permParser.tests.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index a34288b190..fa99451b06 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -275,7 +275,7 @@ describe('permParser', () => { endpoints.assetWithMulti.results[0].user ); - const built = permParser.buildFormData(parsed[2].permissions); + const built = permParser.buildFormData(parsed[1].permissions); chai.expect(built).to.deep.equal({ formView: true, From 38063382b5c48733241e95718a9e7f75ad6a015c Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 30 May 2019 11:18:50 +0200 Subject: [PATCH 110/499] don't send implied and contradictory permissions --- jsapp/js/components/permissions/permParser.es6 | 3 --- 1 file changed, 3 deletions(-) diff --git a/jsapp/js/components/permissions/permParser.es6 b/jsapp/js/components/permissions/permParser.es6 index d11cd0cc1d..1f374f96d4 100644 --- a/jsapp/js/components/permissions/permParser.es6 +++ b/jsapp/js/components/permissions/permParser.es6 @@ -327,9 +327,6 @@ export const permParser = { buildFormData: buildFormData, parseBackendData: parseBackendData, parseOldBackendData: parseOldBackendData, -<<<<<<< HEAD parseUserWithPermsList: parseUserWithPermsList, -======= ->>>>>>> clearer name sortParseBackendOutput: sortParseBackendOutput // for testing purposes }; From a96b95fdb2914086e53259f5ef8054a30f482ad6 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 31 May 2019 10:51:06 +0200 Subject: [PATCH 111/499] handle removing unchecked form options ("permissions") --- jsapp/js/actions/permissions.es6 | 17 ++++ .../js/components/permissions/permParser.es6 | 29 +++++- .../permissions/permParser.tests.es6 | 63 +++++++++++++ .../permissions/userAssetPermsEditor.es6 | 91 ++++++++++++++++++- .../permissions/userPermissionRow.es6 | 12 +++ jsapp/js/dataInterface.es6 | 15 ++- 6 files changed, 223 insertions(+), 4 deletions(-) diff --git a/jsapp/js/actions/permissions.es6 b/jsapp/js/actions/permissions.es6 index ff891e3956..df223cacf6 100644 --- a/jsapp/js/actions/permissions.es6 +++ b/jsapp/js/actions/permissions.es6 @@ -83,10 +83,17 @@ permissionsActions.assignCollectionPermission.listen((uid, perm) => { }); /** +<<<<<<< HEAD * For adding single asset permission * * @param {string} assetUid * @param {Object} perm - permission to add +======= + * For setting an array of permissions (each permission needs to be a separate call) + * + * @param {string} assetUid + * @param {Object[]} perms - list of permissions to add +>>>>>>> handle removing unchecked form options ("permissions") */ permissionsActions.assignAssetPermission.listen((assetUid, perm) => { dataInterface.assignAssetPermission(assetUid, perm) @@ -101,6 +108,7 @@ permissionsActions.assignAssetPermission.listen((assetUid, perm) => { }); /** +<<<<<<< HEAD * For removing single permission * * @param {string} assetUid @@ -108,6 +116,15 @@ permissionsActions.assignAssetPermission.listen((assetUid, perm) => { */ permissionsActions.removeAssetPermission.listen((assetUid, perm) => { dataInterface.removePermission(perm) +======= + * For removing an array of permissions + * + * @param {string} assetUid + * @param {string[]} perms - list of permissions urls to remove + */ +permissionsActions.removeAssetPermissions.listen((assetUid, perms) => { + dataInterface.removeAssetPermissions(perms) +>>>>>>> handle removing unchecked form options ("permissions") .done(() => { permissionsActions.getAssetPermissions(assetUid); permissionsActions.removeAssetPermission.completed(); diff --git a/jsapp/js/components/permissions/permParser.es6 b/jsapp/js/components/permissions/permParser.es6 index 1f374f96d4..fd89c64c26 100644 --- a/jsapp/js/components/permissions/permParser.es6 +++ b/jsapp/js/components/permissions/permParser.es6 @@ -47,9 +47,10 @@ import { * Removes contradictory and implied permissions from final output. * * @param {FormData} data + * @param {boolean} doCleanup - Should contradictory and implied permissions be removed from final data. * @returns {BackendPerm[]} - An array of permissions to be given. */ -function parseFormData(data) { +function parseFormData(data, doCleanup = true) { let parsed = []; if (data.formView) { @@ -127,6 +128,31 @@ function removeImpliedPerms(parsed) { return parsed; } +/** + * Returns a list of permissions that are missing from the first list. + * + * @param {BackendPerm[]} beforePerms - Old permissions. + * @param {BackendPerm[]} afterPerms - New permissions. + * @returns {BackendPerm[]} - Removed permissions. + */ +function getRemovedPerms(beforePerms, afterPerms) { + let removedPerms = []; + + beforePerms.forEach((beforePerm) => { + let isInAfter = false; + afterPerms.forEach((afterPerm) => { + if (beforePerm.permission === afterPerm.permission) { + isInAfter = true; + } + }); + if (!isInAfter) { + removedPerms.push(beforePerm); + } + }); + + return removedPerms; +} + /** * @param {string} username * @param {string} permissionCodename @@ -325,6 +351,7 @@ function sortParseBackendOutput(output) { export const permParser = { parseFormData: parseFormData, buildFormData: buildFormData, + getRemovedPerms: getRemovedPerms, parseBackendData: parseBackendData, parseOldBackendData: parseOldBackendData, parseUserWithPermsList: parseUserWithPermsList, diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index fa99451b06..7f8a71058e 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -355,4 +355,67 @@ describe('permParser', () => { ]); }); }); + + describe('getRemovedPerms', () => { + it('should return only removed permissions', () => { + const beforePerms = [ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/view_asset/' + }, + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/change_asset/' + }, + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/validate_submissions/' + } + ]; + const afterPerms = [ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/view_asset/' + }, + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/add_submissions/' + } + ]; + const parsedRemoved = permParser.getRemovedPerms(beforePerms, afterPerms); + + chai.expect(parsedRemoved).to.deep.equal([ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/change_asset/' + }, + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/validate_submissions/' + } + ]); + }); + + it('should return empty array if nothing removed', () => { + const beforePerms = [ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/view_asset/' + } + ]; + const afterPerms = [ + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/view_asset/' + }, + { + user: '/api/v2/users/leszek/', + permission: '/api/v2/permissions/change_asset/' + } + ]; + const parsedRemoved = permParser.getRemovedPerms(beforePerms, afterPerms); + + chai.expect(parsedRemoved.length).to.equal(0); + }); + }); }); diff --git a/jsapp/js/components/permissions/userAssetPermsEditor.es6 b/jsapp/js/components/permissions/userAssetPermsEditor.es6 index 546c204dc2..236326d678 100644 --- a/jsapp/js/components/permissions/userAssetPermsEditor.es6 +++ b/jsapp/js/components/permissions/userAssetPermsEditor.es6 @@ -36,7 +36,8 @@ class UserAssetPermsEditor extends React.Component { this.state = { // inner workings usernamesBeingChecked: new Set(), - isSubmitPending: false, + isSubmitSetPending: false, + isSubmitRemovePending: false, isEditingUsername: false, isAddingPartialUsernames: false, // form user inputs @@ -74,6 +75,7 @@ class UserAssetPermsEditor extends React.Component { } componentDidMount() { +<<<<<<< HEAD this.listenTo(actions.permissions.bulkSetAssetPermissions.completed, this.onBulkSetAssetPermissionCompleted); this.listenTo(actions.permissions.bulkSetAssetPermissions.failed, this.onBulkSetAssetPermissionFailed); this.listenTo(stores.userExists, this.onUserExistsStoreChange); @@ -86,12 +88,43 @@ class UserAssetPermsEditor extends React.Component { onBulkSetAssetPermissionFailed() { this.setState({isSubmitPending: false}); +======= + this.listenTo(actions.permissions.setAssetPermissions.completed, this.onSetAssetPermissionsCompleted); + this.listenTo(actions.permissions.setAssetPermissions.failed, this.onSetAssetPermissionsFailed); + this.listenTo(actions.permissions.removeAssetPermissions.completed, this.onRemoveAssetPermissionsCompleted); + this.listenTo(actions.permissions.removeAssetPermissions.failed, this.onRemoveAssetPermissionsFailed); + this.listenTo(stores.userExists, this.onUserExistsStoreChange); + } + + onSetAssetPermissionsCompleted() { + this.setState({isSubmitSetPending: false}); + this.notifyParentAboutSubmitEnd(true); + } + + onSetAssetPermissionsFailed() { + this.setState({isSubmitSetPending: false}); + this.notifyParentAboutSubmitEnd(false); + } + + onRemoveAssetPermissionsCompleted() { + this.setState({isSubmitRemovePending: false}); + this.notifyParentAboutSubmitEnd(true); + } + + onRemoveAssetPermissionsFailed() { + this.setState({isSubmitRemovePending: false}); +>>>>>>> handle removing unchecked form options ("permissions") this.notifyParentAboutSubmitEnd(false); } notifyParentAboutSubmitEnd(isSuccess) { if ( +<<<<<<< HEAD !this.state.isSubmitPending && +======= + !this.state.isSubmitSetPending && + !this.state.isSubmitRemovePending && +>>>>>>> handle removing unchecked form options ("permissions") typeof this.props.onSubmitEnd === 'function' ) { this.props.onSubmitEnd(isSuccess); @@ -328,8 +361,13 @@ class UserAssetPermsEditor extends React.Component { const isPartialValid = this.state.submissionsViewPartial ? this.state.submissionsViewPartialUsers.length !== 0 : true; return ( isAnyCheckboxChecked && +<<<<<<< HEAD isPartialValid && !this.state.isSubmitPending && +======= + !this.state.isSubmitSetPending && + !this.state.isSubmitRemovePending && +>>>>>>> handle removing unchecked form options ("permissions") !this.state.isEditingUsername && !this.state.isAddingPartialUsernames && this.state.username.length > 0 && @@ -339,6 +377,7 @@ class UserAssetPermsEditor extends React.Component { ); } +<<<<<<< HEAD /** * Returns only the properties for assignable permissions */ @@ -385,6 +424,54 @@ class UserAssetPermsEditor extends React.Component { } return false; +======= + getFormData() { + return { + username: this.state.username, + formView: this.state.formView, + formEdit: this.state.formEdit, + submissionsView: this.state.submissionsView, + submissionsViewPartial: this.state.submissionsViewPartial, + submissionsViewPartialUsers: this.state.submissionsViewPartialUsers, + submissionsAdd: this.state.submissionsAdd, + submissionsEdit: this.state.submissionsEdit, + submissionsValidate: this.state.submissionsValidate + }; + } + + submit() { + if (!this.isSubmitEnabled()) { + return; + } + + const parsed = permParser.parseFormData(this.getFormData()); + const parsedDirty = permParser.parseFormData(this.getFormData(), false); + const parsedRemoved = permParser.getRemovedPerms(this.props.permissions, parsedDirty); + + console.debug('TODO: first find permissions that were checked but got unchecked and remove them'); + console.debug('TODO: second find permissions that were unchecked but got checked and add them'); + console.debug('TODO: do it for UserCollectionPermsEditor too'); + + if (parsedRemoved.length > 0) { + actions.permissions.removeAssetPermissions( + this.props.uid, + parsedRemoved + ); + this.setState({isSubmitRemovePending: true}); + } + if (parsed.length > 0) { + actions.permissions.setAssetPermissions( + this.props.uid, + parsed + ); + this.setState({isSubmitSetPending: true}); + } + + // if nothing changes but user wants to submit, just notify parent we're good + if (parsedRemoved.length === 0 && parsed.length === 0) { + this.notifyParentAboutSubmitEnd(true); + } +>>>>>>> handle removing unchecked form options ("permissions") } render() { @@ -406,7 +493,7 @@ class UserAssetPermsEditor extends React.Component { } const formModifiers = []; - if (this.state.isSubmitPending) { + if (this.state.isSubmitSetPending || this.state.isSubmitRemovePending) { formModifiers.push('pending'); } diff --git a/jsapp/js/components/permissions/userPermissionRow.es6 b/jsapp/js/components/permissions/userPermissionRow.es6 index 95cd88dfa0..c7e5bff696 100644 --- a/jsapp/js/components/permissions/userPermissionRow.es6 +++ b/jsapp/js/components/permissions/userPermissionRow.es6 @@ -62,6 +62,7 @@ class UserPermissionRow extends React.Component { dialog.set(opts).show(); } +<<<<<<< HEAD /** * Note: we remove "view_asset"/"view_collection" permission, as it is * the most basic one, so removing it will in fact remove all permissions @@ -76,6 +77,17 @@ class UserPermissionRow extends React.Component { actionFn = actions.permissions.removeCollectionPermission; targetPermUrl = permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.get('view_collection')).url; } +======= + removeAssetPermissions() { + this.setState({isBeingDeleted: true}); + // we remove "view_asset" permission, as it is the most basic one, so removing it + // will in fact remove all permissions + const userViewAssetPerm = this.props.permissions.find((perm) => { + return perm.permission === permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.get('view_asset')).url; + }); + actions.permissions.removeAssetPermissions(this.props.uid, [userViewAssetPerm.url]); + } +>>>>>>> handle removing unchecked form options ("permissions") this.setState({isBeingDeleted: true}); diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 82da7e3d56..40f70d07a3 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -230,6 +230,7 @@ export var dataInterface; }); }, +<<<<<<< HEAD bulkSetAssetPermissions(assetUid, perms) { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/permission-assignments/bulk/`, @@ -237,7 +238,19 @@ export var dataInterface; data: JSON.stringify(perms), dataType: 'json', contentType: 'application/json' - }); +======= + removeAssetPermissions(perms) { + const $ajaxCalls = []; + perms.forEach((perm) => { + $ajaxCalls.push( + $ajax({ + url: perm, + method: 'DELETE' + }) + ); +>>>>>>> handle removing unchecked form options ("permissions") + }); + return $.when.apply(undefined, $ajaxCalls); }, assignAssetPermission(assetUid, perm) { From 774bc2ed97b50db547f3ea881a605208e46edc04 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 4 Jun 2019 21:58:12 +0200 Subject: [PATCH 112/499] fix changin collection permissions --- .../permissions/userAssetPermsEditor.es6 | 86 ------------------- 1 file changed, 86 deletions(-) diff --git a/jsapp/js/components/permissions/userAssetPermsEditor.es6 b/jsapp/js/components/permissions/userAssetPermsEditor.es6 index 236326d678..3dde08714d 100644 --- a/jsapp/js/components/permissions/userAssetPermsEditor.es6 +++ b/jsapp/js/components/permissions/userAssetPermsEditor.es6 @@ -75,7 +75,6 @@ class UserAssetPermsEditor extends React.Component { } componentDidMount() { -<<<<<<< HEAD this.listenTo(actions.permissions.bulkSetAssetPermissions.completed, this.onBulkSetAssetPermissionCompleted); this.listenTo(actions.permissions.bulkSetAssetPermissions.failed, this.onBulkSetAssetPermissionFailed); this.listenTo(stores.userExists, this.onUserExistsStoreChange); @@ -88,43 +87,12 @@ class UserAssetPermsEditor extends React.Component { onBulkSetAssetPermissionFailed() { this.setState({isSubmitPending: false}); -======= - this.listenTo(actions.permissions.setAssetPermissions.completed, this.onSetAssetPermissionsCompleted); - this.listenTo(actions.permissions.setAssetPermissions.failed, this.onSetAssetPermissionsFailed); - this.listenTo(actions.permissions.removeAssetPermissions.completed, this.onRemoveAssetPermissionsCompleted); - this.listenTo(actions.permissions.removeAssetPermissions.failed, this.onRemoveAssetPermissionsFailed); - this.listenTo(stores.userExists, this.onUserExistsStoreChange); - } - - onSetAssetPermissionsCompleted() { - this.setState({isSubmitSetPending: false}); - this.notifyParentAboutSubmitEnd(true); - } - - onSetAssetPermissionsFailed() { - this.setState({isSubmitSetPending: false}); - this.notifyParentAboutSubmitEnd(false); - } - - onRemoveAssetPermissionsCompleted() { - this.setState({isSubmitRemovePending: false}); - this.notifyParentAboutSubmitEnd(true); - } - - onRemoveAssetPermissionsFailed() { - this.setState({isSubmitRemovePending: false}); ->>>>>>> handle removing unchecked form options ("permissions") this.notifyParentAboutSubmitEnd(false); } notifyParentAboutSubmitEnd(isSuccess) { if ( -<<<<<<< HEAD !this.state.isSubmitPending && -======= - !this.state.isSubmitSetPending && - !this.state.isSubmitRemovePending && ->>>>>>> handle removing unchecked form options ("permissions") typeof this.props.onSubmitEnd === 'function' ) { this.props.onSubmitEnd(isSuccess); @@ -361,13 +329,8 @@ class UserAssetPermsEditor extends React.Component { const isPartialValid = this.state.submissionsViewPartial ? this.state.submissionsViewPartialUsers.length !== 0 : true; return ( isAnyCheckboxChecked && -<<<<<<< HEAD isPartialValid && !this.state.isSubmitPending && -======= - !this.state.isSubmitSetPending && - !this.state.isSubmitRemovePending && ->>>>>>> handle removing unchecked form options ("permissions") !this.state.isEditingUsername && !this.state.isAddingPartialUsernames && this.state.username.length > 0 && @@ -377,7 +340,6 @@ class UserAssetPermsEditor extends React.Component { ); } -<<<<<<< HEAD /** * Returns only the properties for assignable permissions */ @@ -424,54 +386,6 @@ class UserAssetPermsEditor extends React.Component { } return false; -======= - getFormData() { - return { - username: this.state.username, - formView: this.state.formView, - formEdit: this.state.formEdit, - submissionsView: this.state.submissionsView, - submissionsViewPartial: this.state.submissionsViewPartial, - submissionsViewPartialUsers: this.state.submissionsViewPartialUsers, - submissionsAdd: this.state.submissionsAdd, - submissionsEdit: this.state.submissionsEdit, - submissionsValidate: this.state.submissionsValidate - }; - } - - submit() { - if (!this.isSubmitEnabled()) { - return; - } - - const parsed = permParser.parseFormData(this.getFormData()); - const parsedDirty = permParser.parseFormData(this.getFormData(), false); - const parsedRemoved = permParser.getRemovedPerms(this.props.permissions, parsedDirty); - - console.debug('TODO: first find permissions that were checked but got unchecked and remove them'); - console.debug('TODO: second find permissions that were unchecked but got checked and add them'); - console.debug('TODO: do it for UserCollectionPermsEditor too'); - - if (parsedRemoved.length > 0) { - actions.permissions.removeAssetPermissions( - this.props.uid, - parsedRemoved - ); - this.setState({isSubmitRemovePending: true}); - } - if (parsed.length > 0) { - actions.permissions.setAssetPermissions( - this.props.uid, - parsed - ); - this.setState({isSubmitSetPending: true}); - } - - // if nothing changes but user wants to submit, just notify parent we're good - if (parsedRemoved.length === 0 && parsed.length === 0) { - this.notifyParentAboutSubmitEnd(true); - } ->>>>>>> handle removing unchecked form options ("permissions") } render() { From 69d146a04207fb1e6f2d4c6bb7267a93671e2a05 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 20 Jun 2019 21:36:54 +0200 Subject: [PATCH 113/499] introduce and use bulk set permissions --- jsapp/js/actions/permissions.es6 | 310 ++++++++---------- .../js/components/permissions/permParser.es6 | 29 +- .../permissions/permParser.tests.es6 | 78 ++--- .../permissions/userAssetPermsEditor.es6 | 5 +- .../permissions/userPermissionRow.es6 | 90 +---- jsapp/js/dataInterface.es6 | 40 ++- 6 files changed, 216 insertions(+), 336 deletions(-) diff --git a/jsapp/js/actions/permissions.es6 b/jsapp/js/actions/permissions.es6 index df223cacf6..7049cc4a26 100644 --- a/jsapp/js/actions/permissions.es6 +++ b/jsapp/js/actions/permissions.es6 @@ -2,177 +2,139 @@ * permissions related actions */ -import Reflux from 'reflux'; -import RefluxPromise from 'js/libs/reflux-promise'; -Reflux.use(RefluxPromise(window.Promise)); -import {dataInterface} from 'js/dataInterface'; -import { - t, - notify -} from 'js/utils'; - -export const permissionsActions = Reflux.createActions({ - getConfig: {children: ['completed', 'failed']}, - getAssetPermissions: {children: ['completed', 'failed']}, - getCollectionPermissions: {children: ['completed', 'failed']}, - bulkSetAssetPermissions: {children: ['completed', 'failed']}, - assignCollectionPermission: {children: ['completed', 'failed']}, - assignAssetPermission: {children: ['completed', 'failed']}, - removeAssetPermission: {children: ['completed', 'failed']}, - removeCollectionPermission: {children: ['completed', 'failed']}, - copyPermissionsFrom: {children: ['completed', 'failed']}, - assignPublicPerm: {children: ['completed', 'failed']}, - setCollectionDiscoverability: {children: ['completed', 'failed']} -}); - -/** - * New actions - */ - -permissionsActions.getConfig.listen(() => { - dataInterface.getPermissionsConfig() - .done(permissionsActions.getConfig.completed) - .fail(permissionsActions.getConfig.failed); -}); - -permissionsActions.getAssetPermissions.listen((assetUid) => { - dataInterface.getAssetPermissions(assetUid) - .done(permissionsActions.getAssetPermissions.completed) - .fail(permissionsActions.getAssetPermissions.failed); -}); - -permissionsActions.getCollectionPermissions.listen((uid) => { - dataInterface.getCollectionPermissions(uid) - .done(permissionsActions.getCollectionPermissions.completed) - .fail(permissionsActions.getCollectionPermissions.failed); -}); - -/** - * For bulk setting permissions - wipes all current permissions, sets given ones - * - * @param {string} assetUid - * @param {Object[]} perms - permissions to set - */ -permissionsActions.bulkSetAssetPermissions.listen((assetUid, perm) => { - dataInterface.bulkSetAssetPermissions(assetUid, perm) - .done((permissionAssignments) => { - permissionsActions.bulkSetAssetPermissions.completed(permissionAssignments); - }) - .fail(() => { - permissionsActions.getAssetPermissions(assetUid); - permissionsActions.bulkSetAssetPermissions.failed(); - }); -}); - -/** - * For adding single collection permission - * - * @param {string} uid - collection uid - * @param {Object} perm - permission to add - */ -permissionsActions.assignCollectionPermission.listen((uid, perm) => { - dataInterface.assignCollectionPermission(uid, perm) - .done(() => { - permissionsActions.getCollectionPermissions(uid); - permissionsActions.assignCollectionPermission.completed(uid); - }) - .fail(() => { - permissionsActions.getCollectionPermissions(uid); - permissionsActions.assignCollectionPermission.failed(uid); - }); -}); - -/** -<<<<<<< HEAD - * For adding single asset permission - * - * @param {string} assetUid - * @param {Object} perm - permission to add -======= - * For setting an array of permissions (each permission needs to be a separate call) - * - * @param {string} assetUid - * @param {Object[]} perms - list of permissions to add ->>>>>>> handle removing unchecked form options ("permissions") - */ -permissionsActions.assignAssetPermission.listen((assetUid, perm) => { - dataInterface.assignAssetPermission(assetUid, perm) - .done(() => { - permissionsActions.getAssetPermissions(assetUid); - permissionsActions.assignAssetPermission.completed(assetUid); - }) - .fail(() => { - permissionsActions.getAssetPermissions(assetUid); - permissionsActions.assignAssetPermission.failed(assetUid); - }); -}); - -/** -<<<<<<< HEAD - * For removing single permission - * - * @param {string} assetUid - * @param {string} perm - permission url - */ -permissionsActions.removeAssetPermission.listen((assetUid, perm) => { - dataInterface.removePermission(perm) -======= - * For removing an array of permissions - * - * @param {string} assetUid - * @param {string[]} perms - list of permissions urls to remove - */ -permissionsActions.removeAssetPermissions.listen((assetUid, perms) => { - dataInterface.removeAssetPermissions(perms) ->>>>>>> handle removing unchecked form options ("permissions") - .done(() => { - permissionsActions.getAssetPermissions(assetUid); - permissionsActions.removeAssetPermission.completed(); - }) - .fail(() => { - permissionsActions.getAssetPermissions(assetUid); - permissionsActions.removeAssetPermission.failed(); - }); -}); - -/** -<<<<<<< HEAD - * For removing single permission - * - * @param {string} uid - * @param {string} perm - permission url -======= -Old actions ->>>>>>> introduce new editor for collection permissions - */ -permissionsActions.removeCollectionPermission.listen((uid, perm) => { - dataInterface.removePermission(perm) - .done(() => { - permissionsActions.getCollectionPermissions(uid); - permissionsActions.removeCollectionPermission.completed(); - }) - .fail(() => { - permissionsActions.getCollectionPermissions(uid); - permissionsActions.removeCollectionPermission.failed(); - }); -}); - -/** -Old actions - */ - -// copies permissions from one asset to other -permissionsActions.copyPermissionsFrom.listen(function(sourceUid, targetUid) { - dataInterface.copyPermissionsFrom(sourceUid, targetUid) - .done(() => { - permissionsActions.getAssetPermissions(targetUid); - permissionsActions.copyPermissionsFrom.completed(sourceUid, targetUid); - }) - .fail(permissionsActions.copyPermissionsFrom.failed); -}); - -permissionsActions.setCollectionDiscoverability.listen(function(uid, discoverable){ - dataInterface.patchCollection(uid, {discoverable_when_public: discoverable}) - .done(permissionsActions.setCollectionDiscoverability.completed) - .fail(permissionsActions.setCollectionDiscoverability.failed); -}); + import Reflux from 'reflux'; + import RefluxPromise from 'js/libs/reflux-promise'; + Reflux.use(RefluxPromise(window.Promise)); + import {dataInterface} from 'js/dataInterface'; + import {buildUserUrl} from 'utils'; + import { + ANON_USERNAME, + PERMISSIONS_CODENAMES + } from 'js/constants'; + import permConfig from 'js/components/permissions/permConfig'; + + export const permissionsActions = Reflux.createActions({ + getConfig: {children: ['completed', 'failed']}, + getAssetPermissions: {children: ['completed', 'failed']}, + bulkSetAssetPermissions: {children: ['completed', 'failed']}, + assignAssetPermission: {children: ['completed', 'failed']}, + removeAssetPermission: {children: ['completed', 'failed']}, + setAssetPublic: {children: ['completed', 'failed']}, + copyPermissionsFrom: {children: ['completed', 'failed']} + }); + + /** + * New actions + */ + + permissionsActions.getConfig.listen(() => { + dataInterface.getPermissionsConfig() + .done(permissionsActions.getConfig.completed) + .fail(permissionsActions.getConfig.failed); + }); + + permissionsActions.getAssetPermissions.listen((assetUid) => { + dataInterface.getAssetPermissions(assetUid) + .done(permissionsActions.getAssetPermissions.completed) + .fail(permissionsActions.getAssetPermissions.failed); + }); + + /** + * For bulk setting permissions - wipes all current permissions, sets given ones + * + * @param {string} assetUid + * @param {Object[]} perms - permissions to set + */ + permissionsActions.bulkSetAssetPermissions.listen((assetUid, perms) => { + dataInterface.bulkSetAssetPermissions(assetUid, perms) + .done((permissionAssignments) => { + permissionsActions.bulkSetAssetPermissions.completed(permissionAssignments); + }) + .fail(() => { + permissionsActions.getAssetPermissions(assetUid); + permissionsActions.bulkSetAssetPermissions.failed(); + }); + }); + + /** + * For adding single asset permission + * + * @param {string} assetUid + * @param {Object} perm - permission to add + */ + permissionsActions.assignAssetPermission.listen((assetUid, perm) => { + dataInterface.assignAssetPermission(assetUid, perm) + .done(() => { + permissionsActions.getAssetPermissions(assetUid); + permissionsActions.assignAssetPermission.completed(assetUid); + }) + .fail(() => { + permissionsActions.getAssetPermissions(assetUid); + permissionsActions.assignAssetPermission.failed(assetUid); + }); + }); + + /** + * For removing single permission + * + * @param {string} assetUid + * @param {string} perm - permission url + */ + permissionsActions.removeAssetPermission.listen((assetUid, perm) => { + dataInterface.removePermission(perm) + .done(() => { + permissionsActions.getAssetPermissions(assetUid); + permissionsActions.removeAssetPermission.completed(assetUid); + }) + .fail(() => { + permissionsActions.getAssetPermissions(assetUid); + permissionsActions.removeAssetPermission.failed(assetUid); + }); + }); + + /** + * Makes asset public or private. This is a special action that mixes + * bulkSetAssetPermissions and removeAssetPermission to elegantly solve a + * particular problem. + * + * @param {Object} asset - BE asset data + * @param {boolean} shouldSetAnonPerms + */ + permissionsActions.setAssetPublic.listen((asset, shouldSetAnonPerms) => { + if (shouldSetAnonPerms) { + const permsToSet = asset.permissions.filter((permissionAssignment) => { + return permissionAssignment.user !== asset.owner; + }); + permsToSet.push({ + user: buildUserUrl(ANON_USERNAME), + permission: permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.view_asset).url + }); + permsToSet.push({ + user: buildUserUrl(ANON_USERNAME), + permission: permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.discover_asset).url + }); + dataInterface.bulkSetAssetPermissions(asset.uid, permsToSet) + .done(() => {permissionsActions.setAssetPublic.completed(asset.uid, shouldSetAnonPerms);}) + .fail(() => {permissionsActions.setAssetPublic.failed(asset.uid, shouldSetAnonPerms);}); + } else { + const permToRemove = asset.permissions.find((permissionAssignment) => { + return ( + permissionAssignment.user === buildUserUrl(ANON_USERNAME) && + permissionAssignment.permission === permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.view_asset).url + ); + }); + dataInterface.removePermission(permToRemove.url) + .done(() => {permissionsActions.setAssetPublic.completed(asset.uid, shouldSetAnonPerms);}) + .fail(() => {permissionsActions.setAssetPublic.failed(asset.uid, shouldSetAnonPerms);}); + } + }); + + // copies permissions from one asset to other + permissionsActions.copyPermissionsFrom.listen(function(sourceUid, targetUid) { + dataInterface.copyPermissionsFrom(sourceUid, targetUid) + .done(() => { + permissionsActions.getAssetPermissions(targetUid); + permissionsActions.copyPermissionsFrom.completed(sourceUid, targetUid); + }) + .fail(permissionsActions.copyPermissionsFrom.failed); + }); diff --git a/jsapp/js/components/permissions/permParser.es6 b/jsapp/js/components/permissions/permParser.es6 index fd89c64c26..1f374f96d4 100644 --- a/jsapp/js/components/permissions/permParser.es6 +++ b/jsapp/js/components/permissions/permParser.es6 @@ -47,10 +47,9 @@ import { * Removes contradictory and implied permissions from final output. * * @param {FormData} data - * @param {boolean} doCleanup - Should contradictory and implied permissions be removed from final data. * @returns {BackendPerm[]} - An array of permissions to be given. */ -function parseFormData(data, doCleanup = true) { +function parseFormData(data) { let parsed = []; if (data.formView) { @@ -128,31 +127,6 @@ function removeImpliedPerms(parsed) { return parsed; } -/** - * Returns a list of permissions that are missing from the first list. - * - * @param {BackendPerm[]} beforePerms - Old permissions. - * @param {BackendPerm[]} afterPerms - New permissions. - * @returns {BackendPerm[]} - Removed permissions. - */ -function getRemovedPerms(beforePerms, afterPerms) { - let removedPerms = []; - - beforePerms.forEach((beforePerm) => { - let isInAfter = false; - afterPerms.forEach((afterPerm) => { - if (beforePerm.permission === afterPerm.permission) { - isInAfter = true; - } - }); - if (!isInAfter) { - removedPerms.push(beforePerm); - } - }); - - return removedPerms; -} - /** * @param {string} username * @param {string} permissionCodename @@ -351,7 +325,6 @@ function sortParseBackendOutput(output) { export const permParser = { parseFormData: parseFormData, buildFormData: buildFormData, - getRemovedPerms: getRemovedPerms, parseBackendData: parseBackendData, parseOldBackendData: parseOldBackendData, parseUserWithPermsList: parseUserWithPermsList, diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index 7f8a71058e..6ef5354f6b 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -356,66 +356,52 @@ describe('permParser', () => { }); }); - describe('getRemovedPerms', () => { - it('should return only removed permissions', () => { - const beforePerms = [ + describe('parseUserWithPermsList', () => { + it('should return flat list of permissions', () => { + const userWithPermsList = permParser.parseBackendData( + endpoints.assetWithMulti.results, + endpoints.assetWithMulti.results[0].user + ); + const parsed = permParser.parseUserWithPermsList(userWithPermsList); + + chai.expect(parsed).to.deep.equal([ { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/view_asset/' + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/add_submissions/' }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/change_asset/' + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/change_asset/' }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/validate_submissions/' - } - ]; - const afterPerms = [ - { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/view_asset/' + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/change_submissions/' }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/add_submissions/' - } - ]; - const parsedRemoved = permParser.getRemovedPerms(beforePerms, afterPerms); - - chai.expect(parsedRemoved).to.deep.equal([ + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/validate_submissions/' + }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/change_asset/' + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/view_asset/' }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/validate_submissions/' - } - ]); - }); - - it('should return empty array if nothing removed', () => { - const beforePerms = [ + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/view_submissions/' + }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/view_asset/' - } - ]; - const afterPerms = [ + 'user': '/api/v2/users/john/', + 'permission': '/api/v2/permissions/view_submissions/' + }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/view_asset/' + 'user': '/api/v2/users/john/', + 'permission': '/api/v2/permissions/view_asset/' }, { - user: '/api/v2/users/leszek/', - permission: '/api/v2/permissions/change_asset/' - } - ]; - const parsedRemoved = permParser.getRemovedPerms(beforePerms, afterPerms); - - chai.expect(parsedRemoved.length).to.equal(0); + 'user': '/api/v2/users/oliver/', + 'permission': '/api/v2/permissions/view_asset/' + }, + ]); }); }); }); diff --git a/jsapp/js/components/permissions/userAssetPermsEditor.es6 b/jsapp/js/components/permissions/userAssetPermsEditor.es6 index 3dde08714d..546c204dc2 100644 --- a/jsapp/js/components/permissions/userAssetPermsEditor.es6 +++ b/jsapp/js/components/permissions/userAssetPermsEditor.es6 @@ -36,8 +36,7 @@ class UserAssetPermsEditor extends React.Component { this.state = { // inner workings usernamesBeingChecked: new Set(), - isSubmitSetPending: false, - isSubmitRemovePending: false, + isSubmitPending: false, isEditingUsername: false, isAddingPartialUsernames: false, // form user inputs @@ -407,7 +406,7 @@ class UserAssetPermsEditor extends React.Component { } const formModifiers = []; - if (this.state.isSubmitSetPending || this.state.isSubmitRemovePending) { + if (this.state.isSubmitPending) { formModifiers.push('pending'); } diff --git a/jsapp/js/components/permissions/userPermissionRow.es6 b/jsapp/js/components/permissions/userPermissionRow.es6 index c7e5bff696..5529c14737 100644 --- a/jsapp/js/components/permissions/userPermissionRow.es6 +++ b/jsapp/js/components/permissions/userPermissionRow.es6 @@ -1,4 +1,3 @@ -import _ from 'underscore'; import React from 'react'; import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; @@ -8,18 +7,12 @@ import mixins from 'js/mixins'; import {stores} from 'js/stores'; import {actions} from 'js/actions'; import {bem} from 'js/bem'; +import {stringToColor} from 'utils'; import { - t, - stringToColor, -} from 'js/utils'; -import { - KEY_CODES, - ASSET_KINDS, - PERMISSIONS_CODENAMES, - COLLECTION_PERMISSIONS + ASSET_TYPES, + PERMISSIONS_CODENAMES } from 'js/constants'; import UserAssetPermsEditor from './userAssetPermsEditor'; -import UserCollectionPermsEditor from './userCollectionPermsEditor'; import permConfig from './permConfig'; class UserPermissionRow extends React.Component { @@ -44,14 +37,6 @@ class UserPermissionRow extends React.Component { removePermissions() { const dialog = alertify.dialog('confirm'); - - let okCallback; - if (this.props.kind === ASSET_KINDS.get('asset')) { - okCallback = this.removeAssetPermissions; - } else if (this.props.kind === ASSET_KINDS.get('collection')) { - okCallback = this.removeCollectionPermissions; - } - const opts = { title: t('Remove permissions?'), message: t('This action will remove all permissions for user ##username##').replace('##username##', `${this.props.user.name}`), @@ -62,39 +47,16 @@ class UserPermissionRow extends React.Component { dialog.set(opts).show(); } -<<<<<<< HEAD /** - * Note: we remove "view_asset"/"view_collection" permission, as it is - * the most basic one, so removing it will in fact remove all permissions + * Note: we remove "view_asset" permission, as it is the most basic one, + * so removing it will in fact remove all permissions */ removeAllPermissions() { - let actionFn; - let targetPermUrl; - if (this.props.kind === ASSET_KINDS.get('asset')) { - actionFn = actions.permissions.removeAssetPermission; - targetPermUrl = permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.get('view_asset')).url; - } else if (this.props.kind === ASSET_KINDS.get('collection')) { - actionFn = actions.permissions.removeCollectionPermission; - targetPermUrl = permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.get('view_collection')).url; - } -======= - removeAssetPermissions() { this.setState({isBeingDeleted: true}); - // we remove "view_asset" permission, as it is the most basic one, so removing it - // will in fact remove all permissions const userViewAssetPerm = this.props.permissions.find((perm) => { - return perm.permission === permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.get('view_asset')).url; + return perm.permission === permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.view_asset).url; }); - actions.permissions.removeAssetPermissions(this.props.uid, [userViewAssetPerm.url]); - } ->>>>>>> handle removing unchecked form options ("permissions") - - this.setState({isBeingDeleted: true}); - - const userViewAssetPerm = this.props.permissions.find((perm) => { - return perm.permission === targetPermUrl; - }); - actionFn(this.props.uid, userViewAssetPerm.url); + actions.permissions.removeAssetPermission(this.props.uid, userViewAssetPerm.url); } onPermissionsEditorSubmitEnd(isSuccess) { @@ -125,14 +87,8 @@ class UserPermissionRow extends React.Component { } let permName = '???'; - // TODO simplify this code when https://github.com/kobotoolbox/kpi/issues/2332 is done - if (this.props.kind === ASSET_KINDS.get('asset')) { - if (this.props.assignablePerms.has(perm.permission)) { - permName = this.props.assignablePerms.get(perm.permission); - } - } - if (this.props.kind === ASSET_KINDS.get('collection')) { - permName = COLLECTION_PERMISSIONS[permConfig.getPermission(perm.permission).codename]; + if (this.props.assignablePerms.has(perm.permission)) { + permName = this.props.assignablePerms.get(perm.permission); } // Hopefully this is friendly to translators of RTL languages @@ -216,26 +172,14 @@ class UserPermissionRow extends React.Component { {this.state.isEditFormVisible && - {/* TODO simplify this code when https://github.com/kobotoolbox/kpi/issues/2332 is done */} - {this.props.kind === ASSET_KINDS.get('asset') && - - } - {this.props.kind === ASSET_KINDS.get('collection') && - - } - + } diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 40f70d07a3..abac3107a0 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -223,6 +223,7 @@ export var dataInterface; }); }, + getCollectionPermissions(uid) { return $ajax({ url: `${ROOT_URL}/api/v2/collections/${uid}/permission-assignments/`, @@ -230,7 +231,6 @@ export var dataInterface; }); }, -<<<<<<< HEAD bulkSetAssetPermissions(assetUid, perms) { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/permission-assignments/bulk/`, @@ -238,21 +238,37 @@ export var dataInterface; data: JSON.stringify(perms), dataType: 'json', contentType: 'application/json' -======= - removeAssetPermissions(perms) { - const $ajaxCalls = []; - perms.forEach((perm) => { - $ajaxCalls.push( - $ajax({ - url: perm, - method: 'DELETE' - }) - ); ->>>>>>> handle removing unchecked form options ("permissions") }); return $.when.apply(undefined, $ajaxCalls); }, + bulkSetAssetPermissions(assetUid, perms) { + return $ajax({ + url: `${ROOT_URL}/api/v2/assets/${assetUid}/permissions/bulk/`, + method: 'POST', + data: JSON.stringify(perms), + dataType: 'json', + contentType: 'application/json' + }); + }, + + assignAssetPermission(assetUid, perm) { + return $ajax({ + url: `${ROOT_URL}/api/v2/assets/${assetUid}/permissions/`, + method: 'POST', + data: JSON.stringify(perm), + dataType: 'json', + contentType: 'application/json' + }); + }, + + removeAssetPermission(perm) { + return $ajax({ + url: perm, + method: 'DELETE' + }); + }, + assignAssetPermission(assetUid, perm) { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/permission-assignments/`, From 1103b7b801c0b0a7f17447bcb7b1b211e6bf1963 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Mon, 5 Aug 2019 11:08:02 +0200 Subject: [PATCH 114/499] typo fix --- jsapp/js/components/permissions/permParser.tests.es6 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index 6ef5354f6b..ef175203fb 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -295,7 +295,7 @@ describe('permParser', () => { formView: true, submissionsView: true, submissionsViewPartial: true, - submissionsViewPartialUsers: ['john', 'oliver'] + submissionsViewPartialUsers: ['john', 'olivier'] }); }); }); @@ -333,7 +333,7 @@ describe('permParser', () => { formEdit: false, submissionsView: true, submissionsViewPartial: true, - submissionsViewPartialUsers: ['john', 'oliver', 'eric'], + submissionsViewPartialUsers: ['john', 'olivier', 'eric'], submissionsAdd: false, submissionsEdit: false, submissionsValidate: false @@ -347,7 +347,7 @@ describe('permParser', () => { { url: '/api/v2/permissions/view_submissions/', filters: [ - {'_submitted_by': {'$in': ['john', 'oliver', 'eric']}} + {'_submitted_by': {'$in': ['john', 'olivier', 'eric']}} ] } ] @@ -398,7 +398,7 @@ describe('permParser', () => { 'permission': '/api/v2/permissions/view_asset/' }, { - 'user': '/api/v2/users/oliver/', + 'user': '/api/v2/users/olivier/', 'permission': '/api/v2/permissions/view_asset/' }, ]); From 6685f3f42043b0585aa9a6dad732c502c6def65f Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Mon, 5 Aug 2019 12:17:02 +0200 Subject: [PATCH 115/499] remove unused method --- .../js/components/permissions/permConfig.es6 | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/jsapp/js/components/permissions/permConfig.es6 b/jsapp/js/components/permissions/permConfig.es6 index dae209d3c5..3b4643ab9d 100644 --- a/jsapp/js/components/permissions/permConfig.es6 +++ b/jsapp/js/components/permissions/permConfig.es6 @@ -93,27 +93,6 @@ const permConfig = Reflux.createStore({ if (this.state.permissions.length === 0) { throw new Error(t('Permission config is not ready or failed to initialize!')); } - }, - - /** - * Returns a list of available permissions for given asset type. - */ - getAvailablePermissions(assetType) { - if (assetType === 'survey') { - return [ - {value: 'view', label: t('View Form')}, - {value: 'change', label: t('Edit Form')}, - {value: 'view_submissions', label: t('View Submissions')}, - {value: 'add_submissions', label: t('Add Submissions')}, - {value: 'change_submissions', label: t('Edit Submissions')}, - {value: 'validate_submissions', label: t('Validate Submissions')} - ]; - } else { - return [ - {value: 'view', label: t('View')}, - {value: 'change', label: t('Edit')}, - ]; - } } }); From 67d97830ea13fcf4c323c495822752f831a55408 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Thu, 8 Aug 2019 13:37:35 -0400 Subject: [PATCH 116/499] Convert mixins to new-style Python classes --- kpi/utils/object_permission_helper.py | 2 +- kpi/utils/viewset_mixins.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kpi/utils/object_permission_helper.py b/kpi/utils/object_permission_helper.py index fc9044f0ea..83c86a7f45 100644 --- a/kpi/utils/object_permission_helper.py +++ b/kpi/utils/object_permission_helper.py @@ -4,7 +4,7 @@ from kpi.constants import PERM_SHARE_SUBMISSIONS, PERM_FROM_KC_ONLY -class ObjectPermissionHelper: +class ObjectPermissionHelper(object): @staticmethod def user_can_share(affected_object, user_object, codename=''): diff --git a/kpi/utils/viewset_mixins.py b/kpi/utils/viewset_mixins.py index 6418622aa5..d63e23fae8 100644 --- a/kpi/utils/viewset_mixins.py +++ b/kpi/utils/viewset_mixins.py @@ -4,7 +4,7 @@ from kpi.models import Asset, Collection -class AssetNestedObjectViewsetMixin: +class AssetNestedObjectViewsetMixin(object): @property def asset(self): @@ -21,7 +21,7 @@ def asset_uid(self): return self._asset_uid -class CollectionNestedObjectViewsetMixin: +class CollectionNestedObjectViewsetMixin(object): @property def collection(self): From e88cb1521372b398523062131bfd46429bf8f907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 8 Aug 2019 17:54:58 -0400 Subject: [PATCH 117/499] Block permission assignments on owner with bulk assignments (asset & collection) --- kpi/serializers/v2/asset_permission.py | 4 +- kpi/serializers/v2/collection_permission.py | 3 ++ kpi/views/v2/asset_permission.py | 41 ++++++++++++--------- kpi/views/v2/collection_permission.py | 38 +++++++++++-------- 4 files changed, 51 insertions(+), 35 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 150f459a66..0fead3be3d 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -2,7 +2,6 @@ from __future__ import absolute_import from collections import defaultdict -from urlparse import urlparse from django.contrib.auth.models import Permission, User from django.core.urlresolvers import resolve, Resolver404 @@ -49,6 +48,9 @@ class Meta: def create(self, validated_data): user = validated_data['user'] asset = validated_data['asset'] + if asset.owner_id == user.id: + raise serializers.ValidationError({ + 'user': "Owner's permissions cannot be assigned explicitly"}) permission = validated_data['permission'] partial_permissions = validated_data.get('partial_permissions', None) return asset.assign_perm(user, permission.codename, diff --git a/kpi/serializers/v2/collection_permission.py b/kpi/serializers/v2/collection_permission.py index f4402ef100..26715db227 100644 --- a/kpi/serializers/v2/collection_permission.py +++ b/kpi/serializers/v2/collection_permission.py @@ -40,6 +40,9 @@ class Meta: def create(self, validated_data): user = validated_data['user'] collection = validated_data['collection'] + if collection.owner_id == user.id: + raise serializers.ValidationError({ + 'user': "Owner's permissions cannot be assigned explicitly"}) permission = validated_data['permission'] return collection.assign_perm(user, permission.codename) diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py index 0e9b78c539..a6bc3bf1ba 100644 --- a/kpi/views/v2/asset_permission.py +++ b/kpi/views/v2/asset_permission.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, unicode_literals +from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from rest_framework import exceptions, viewsets, status, renderers @@ -165,24 +166,28 @@ def bulk_assignments(self, request, *args, **kwargs): assignments = request.data - # First delete all assignments before assigning new ones. - # If something fails later, this query should rollback - self.asset.permissions.exclude(user__username=self.asset.owner.username).delete() - - for assignment in assignments: - context_ = dict(self.get_serializer_context()) - if 'partial_permissions' in assignment: - context_.update({'partial_permissions': assignment['partial_permissions']}) - serializer = AssetBulkInsertPermissionSerializer( - data=assignment, - context=context_ - ) - serializer.is_valid(raise_exception=True) - serializer.save(asset=self.asset) - - # returns asset permissions. Users who can change permissions can - # see all permissions. - return self.list(request, *args, **kwargs) + # We don't want to lock tables, only queries to rollback in case + # one assignment fails. + with transaction.atomic(): + + # First delete all assignments before assigning new ones. + # If something fails later, this query should rollback + self.asset.permissions.exclude(user__username=self.asset.owner.username).delete() + + for assignment in assignments: + context_ = dict(self.get_serializer_context()) + if 'partial_permissions' in assignment: + context_.update({'partial_permissions': assignment['partial_permissions']}) + serializer = AssetBulkInsertPermissionSerializer( + data=assignment, + context=context_ + ) + serializer.is_valid(raise_exception=True) + serializer.save(asset=self.asset) + + # returns asset permissions. Users who can change permissions can + # see all permissions. + return self.list(request, *args, **kwargs) @list_route(methods=['PATCH'], renderer_classes=[renderers.JSONRenderer]) def clone(self, request, *args, **kwargs): diff --git a/kpi/views/v2/collection_permission.py b/kpi/views/v2/collection_permission.py index 00400f0906..7d9aa2c5d3 100644 --- a/kpi/views/v2/collection_permission.py +++ b/kpi/views/v2/collection_permission.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, unicode_literals +from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils.translation import ugettext as _ from rest_framework import exceptions, viewsets, status, renderers @@ -153,22 +154,27 @@ def bulk_assignments(self, request, *args, **kwargs): assignments = request.data - # First delete all assignments before assigning new ones. - # If something fails later, this query should rollback - self.collection.permissions.exclude(user__username=self.collection.owner.username).delete() - - for assignment in assignments: - context_ = dict(self.get_serializer_context()) - serializer = CollectionBulkInsertPermissionSerializer( - data=assignment, - context=context_ - ) - serializer.is_valid(raise_exception=True) - serializer.save(collection=self.collection) - - # returns collection permissions. Users who can change permissions can - # see all permissions. - return self.list(request, *args, **kwargs) + # We don't want to lock tables, only queries to rollback in case + # one assignment fails. + with transaction.atomic(): + + # First delete all assignments before assigning new ones. + # If something fails later, this query should rollback + self.collection.permissions.exclude( + user__username=self.collection.owner.username).delete() + + for assignment in assignments: + context_ = dict(self.get_serializer_context()) + serializer = CollectionBulkInsertPermissionSerializer( + data=assignment, + context=context_ + ) + serializer.is_valid(raise_exception=True) + serializer.save(collection=self.collection) + + # returns collection permissions. Users who can change permissions can + # see all permissions. + return self.list(request, *args, **kwargs) @list_route(methods=['PATCH'], renderer_classes=[renderers.JSONRenderer]) def clone(self, request, *args, **kwargs): From 1cb0e9af5115a092821deaeb9500f854bfeab44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 8 Aug 2019 17:55:40 -0400 Subject: [PATCH 118/499] Added new tests for bulk assignment --- kpi/tests/api/v2/test_api_asset_permission.py | 62 ++++++++++++++++++- .../api/v2/test_api_collection_permission.py | 62 ++++++++++++++++++- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/kpi/tests/api/v2/test_api_asset_permission.py b/kpi/tests/api/v2/test_api_asset_permission.py index f7fcc948aa..74617541f5 100644 --- a/kpi/tests/api/v2/test_api_asset_permission.py +++ b/kpi/tests/api/v2/test_api_asset_permission.py @@ -26,6 +26,10 @@ def setUp(self): self.client.login(username='admin', password='pass') self.asset = self.create_asset('An asset to be shared') + self.admin_detail_url = reverse( + self._get_endpoint('user-detail'), + kwargs={'username': self.admin.username}) + self.someuser_detail_url = reverse( self._get_endpoint('user-detail'), kwargs={'username': self.someuser.username}) @@ -47,9 +51,6 @@ def setUp(self): kwargs={'parent_lookup_asset': self.asset.uid} ) - -class ApiAssetPermissionTestCase(BaseApiAssetPermissionTestCase): - def _logged_user_gives_permission(self, username, permission): """ Uses the API to grant `permission` to `username` @@ -62,6 +63,9 @@ def _logged_user_gives_permission(self, username, permission): data, format='json') return response + +class ApiAssetPermissionTestCase(BaseApiAssetPermissionTestCase): + def test_owner_can_give_permissions(self): # Current user is `self.admin` response = self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) @@ -198,3 +202,55 @@ def test_anonymous_get_only_owner_s_assignments(self): obj_perms = sorted(obj_perms, key=lambda element: (element[0], element[1])) self.assertEqual(expected_perms, obj_perms) + + +class ApiBulkAssetPermissionTestCase(BaseApiAssetPermissionTestCase): + + def _logged_user_gives_permissions(self, assignments): + """ + Uses the API to grant `permission` to `username` + """ + url = '{}bulk/'.format(self.asset_permissions_list_url) + + def get_data_template(username_, permission_): + return { + 'user': getattr(self, '{}_detail_url'.format(username_)), + 'permission': getattr(self, '{}_permission_detail_url'.format( + permission_)) + } + + data = [] + for username, permission in assignments: + data.append(get_data_template(username, permission)) + response = self.client.post(url, data, format='json') + return response + + def test_cannot_assign_permissions_to_owner(self): + self._logged_user_gives_permission('someuser', PERM_CHANGE_ASSET) + self.client.login(username='someuser', password='someuser') + response = self._logged_user_gives_permissions([ + ('admin', PERM_VIEW_ASSET), + ('admin', PERM_CHANGE_ASSET) + ]) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_bulk_assign_permissions(self): + # TODO Improve this test + permission_list_response = self.client.get(self.asset_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + total = permission_list_response.data.get('count') + # Add number of permissions added with 'view_asset' + total += len(Asset.get_implied_perms(PERM_VIEW_ASSET)) + 1 + # Add number of permissions added with 'change_asset' + total += len(Asset.get_implied_perms(PERM_CHANGE_ASSET)) + 1 + + response = self._logged_user_gives_permissions([ + ('someuser', PERM_VIEW_ASSET), + ('someuser', PERM_VIEW_ASSET), # Add a duplicate which should not count + ('anotheruser', PERM_CHANGE_ASSET) + ]) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), total) + diff --git a/kpi/tests/api/v2/test_api_collection_permission.py b/kpi/tests/api/v2/test_api_collection_permission.py index 7170f8eaf1..74d0ea6675 100644 --- a/kpi/tests/api/v2/test_api_collection_permission.py +++ b/kpi/tests/api/v2/test_api_collection_permission.py @@ -26,6 +26,10 @@ def setUp(self): self.client.login(username='admin', password='pass') self.collection = self.create_collection('A collection to be shared') + self.admin_detail_url = reverse( + self._get_endpoint('user-detail'), + kwargs={'username': self.admin.username}) + self.someuser_detail_url = reverse( self._get_endpoint('user-detail'), kwargs={'username': self.someuser.username}) @@ -47,9 +51,6 @@ def setUp(self): kwargs={'parent_lookup_collection': self.collection.uid} ) - -class ApiCollectionPermissionTestCase(BaseApiCollectionPermissionTestCase): - def _logged_user_gives_permission(self, username, permission): data = { 'user': getattr(self, '{}_detail_url'.format(username)), @@ -59,6 +60,9 @@ def _logged_user_gives_permission(self, username, permission): data, format='json') return response + +class ApiCollectionPermissionTestCase(BaseApiCollectionPermissionTestCase): + def test_owner_can_give_permissions(self): # Current user is `self.admin` response = self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) @@ -192,3 +196,55 @@ def test_anonymous_get_only_owner_s_assignments(self): obj_perms = sorted(obj_perms, key=lambda element: (element[0], element[1])) self.assertEqual(expected_perms, obj_perms) + + +class ApiBulkCollectionPermissionTestCase(BaseApiCollectionPermissionTestCase): + + def _logged_user_gives_permissions(self, assignments): + """ + Uses the API to grant `permission` to `username` + """ + url = '{}bulk/'.format(self.collection_permissions_list_url) + + def get_data_template(username_, permission_): + return { + 'user': getattr(self, '{}_detail_url'.format(username_)), + 'permission': getattr(self, '{}_permission_detail_url'.format( + permission_)) + } + + data = [] + for username, permission in assignments: + data.append(get_data_template(username, permission)) + + response = self.client.post(url, data, format='json') + return response + + def test_cannot_assign_permissions_to_owner(self): + self._logged_user_gives_permission('someuser', PERM_CHANGE_COLLECTION) + self.client.login(username='someuser', password='someuser') + response = self._logged_user_gives_permissions([ + ('admin', PERM_VIEW_COLLECTION), + ('admin', PERM_CHANGE_COLLECTION) + ]) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_bulk_assign_permissions(self): + # TODO Improve this test + permission_list_response = self.client.get(self.collection_permissions_list_url, + format='json') + self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) + total = permission_list_response.data.get('count') + # Add number of permissions added with 'view_collection' + total += len(Collection.get_implied_perms(PERM_VIEW_COLLECTION)) + 1 + # Add number of permissions added with 'change_collection' + total += len(Collection.get_implied_perms(PERM_CHANGE_COLLECTION)) + 1 + + response = self._logged_user_gives_permissions([ + ('someuser', PERM_VIEW_COLLECTION), + ('someuser', PERM_VIEW_COLLECTION), # Add a duplicate which should not count + ('anotheruser', PERM_CHANGE_COLLECTION) + ]) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('count'), total) From 7f0500303d22156238aa6a404557af714c3d62f1 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Fri, 2 Aug 2019 22:18:35 -0400 Subject: [PATCH 119/499] Remove debugging `print` statement --- kpi/tests/api/v2/test_api_assets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index 6f9641ed26..f5bd55b1b9 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -231,7 +231,6 @@ def setUp(self): self.r = self.client.post(url, data, format='json') self.asset = Asset.objects.get(uid=self.r.data.get('uid')) self.asset_url = self.r.data['url'] - print("ASSET URL {}".format(self.asset_url)) self.assertEqual(self.r.status_code, status.HTTP_201_CREATED) self.asset_uid = self.r.data['uid'] From 3283a5ab56c779e88449fe9ec2e9079b7ecae701 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Fri, 2 Aug 2019 22:16:47 -0400 Subject: [PATCH 120/499] Standardize `Kobocat` in shadow model class names --- kpi/deployment_backends/kc_access/shadow_models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index 122fd867ac..1d0f80ff4b 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -389,7 +389,7 @@ def sync(cls, user): ) -class KCUser(ShadowModel): +class KobocatUser(ShadowModel): username = models.CharField(_("username"), max_length=30) password = models.CharField(_("password"), max_length=128) @@ -410,7 +410,7 @@ def sync(cls, auth_user): try: kc_auth_user = cls.objects.get(pk=auth_user.pk) assert kc_auth_user.username == auth_user.username - except KCUser.DoesNotExist: + except KobocatUser.DoesNotExist: kc_auth_user = cls(pk=auth_user.pk, username=auth_user.username) kc_auth_user.password = auth_user.password @@ -426,7 +426,7 @@ def sync(cls, auth_user): kc_auth_user.save() -class KCToken(ShadowModel): +class KobocatToken(ShadowModel): key = models.CharField(_("Key"), max_length=40, primary_key=True) user = models.OneToOneField(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), @@ -443,7 +443,7 @@ def sync(cls, auth_token): # Token use a One-to-One relationship on User. # Thus, we can retrieve tokens from users' id. kc_auth_token = cls.objects.get(user_id=auth_token.user_id) - except KCToken.DoesNotExist: + except KobocatToken.DoesNotExist: kc_auth_token = cls(pk=auth_token.pk, user=auth_token.user) kc_auth_token.save() From d50e0c419049c42203323b5387ea433c879e185d Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Fri, 2 Aug 2019 23:38:27 -0400 Subject: [PATCH 121/499] Add new permission-related KoBoCAT shadow models These are needed when running KoBoCAT and KPI with separate databases --- .../kc_access/shadow_models.py | 40 +++++++ kpi/signals.py | 105 ------------------ 2 files changed, 40 insertions(+), 105 deletions(-) delete mode 100644 kpi/signals.py diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index 1d0f80ff4b..aa5cac6feb 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -235,6 +235,46 @@ def sync(cls, auth_user): KobocatDigestPartial.sync(kc_auth_user) +class KobocatContentType(ShadowModel): + """ + Minimal representation of Django 1.8's + contrib.contenttypes.models.ContentType + """ + app_label = models.CharField(max_length=100) + model = models.CharField(_('python model class name'), max_length=100) + + class Meta(ShadowModel.Meta): + db_table = 'django_content_type' + unique_together = (('app_label', 'model'),) + + def __str__(self): + # Not as nice as the original, which returns a human-readable name + # complete with whitespace. That requires access to the Python model + # class, though + return self.model + + +class KobocatPermission(ShadowModel): + """ + Minimal representation of Django 1.8's contrib.auth.models.Permission + """ + name = models.CharField(_('name'), max_length=255) + content_type = models.ForeignKey(KobocatContentType) + codename = models.CharField(_('codename'), max_length=100) + + class Meta(ShadowModel.Meta): + db_table = 'auth_permission' + unique_together = (('content_type', 'codename'),) + ordering = ('content_type__app_label', 'content_type__model', + 'codename') + + def __str__(self): + return "%s | %s | %s" % ( + six.text_type(self.content_type.app_label), + six.text_type(self.content_type), + six.text_type(self.name)) + + class KobocatUserObjectPermission(ShadowModel): """ For the _sole purpose_ of letting us manipulate KoBoCAT diff --git a/kpi/signals.py b/kpi/signals.py deleted file mode 100644 index 0114c140e4..0000000000 --- a/kpi/signals.py +++ /dev/null @@ -1,105 +0,0 @@ -# coding: utf-8 -from django.conf import settings -from django.contrib.auth.models import User -from django.db.models.signals import post_save, post_delete -from django.dispatch import receiver -from rest_framework.authtoken.models import Token -from taggit.models import Tag - -from kobo.apps.hook.models.hook import Hook -from kpi.deployment_backends.kc_access.shadow_models import ( - KobocatToken, - KobocatUser, -) -from kpi.deployment_backends.kc_access.utils import grant_kc_model_level_perms -from kpi.models import Asset, TagUid -from kpi.utils.permissions import grant_default_model_level_perms - - -@receiver(post_save, sender=User) -def default_permissions_post_save(sender, instance, created, raw, **kwargs): - """ - Users must have both model-level and object-level permissions to satisfy - DRF, so assign the newly-created user all available collection and asset - permissions at the model level - """ - if raw: - # `raw` means we can't touch (so make sure your fixtures include - # all necessary permissions!) - return - if not created: - # We should only grant default permissions when the user is first - # created - return - grant_default_model_level_perms(instance) - - -@receiver(post_save, sender=User) -def save_kobocat_user(sender, instance, created, raw, **kwargs): - """ - Sync auth_user table between KPI and KC, and, if the user is newly created, - grant all KoBoCAT model-level permissions for the content types listed in - `settings.KOBOCAT_DEFAULT_PERMISSION_CONTENT_TYPES` - """ - if not settings.TESTING: - KobocatUser.sync(instance) - - if created: - # FIXME: If this fails, the next attempt results in - # IntegrityError: duplicate key value violates unique constraint - # "auth_user_username_key" - # and decorating this function with `transaction.atomic` doesn't - # seem to help. We should roll back the KC user creation if - # assigning model-level permissions fails - grant_kc_model_level_perms(instance) - - -@receiver(post_save, sender=Token) -def save_kobocat_token(sender, instance, **kwargs): - """ - Sync AuthToken table between KPI and KC - """ - if not settings.TESTING: - KobocatToken.sync(instance) - - -@receiver(post_delete, sender=Token) -def delete_kobocat_token(sender, instance, **kwargs): - """ - Delete corresponding record from KC AuthToken table - """ - if not settings.TESTING: - try: - KobocatToken.objects.get(pk=instance.pk).delete() - except KobocatToken.DoesNotExist: - pass - - -@receiver(post_save, sender=Tag) -def tag_uid_post_save(sender, instance, created, raw, **kwargs): - """ Make sure we have a TagUid object for each newly-created Tag """ - if raw or not created: - return - TagUid.objects.get_or_create(tag=instance) - - -@receiver(post_save, sender=Hook) -def update_kc_xform_has_kpi_hooks(sender, instance, **kwargs): - """ - Updates `kc.XForm` instance as soon as Asset.Hook list is updated. - """ - asset = instance.asset - if asset.has_deployment: - asset.deployment.set_has_kpi_hooks() - - -@receiver(post_delete, sender=Asset) -def post_delete_asset(sender, instance, **kwargs): - # Update parent's languages if this object is a child of another asset. - try: - parent = instance.parent - except Asset.DoesNotExist: # `parent` may exists in DJANGO models cache but not in DB - pass - else: - if parent: - parent.update_languages() From 8df07b0ff5948cff939c5acd46549bcc1b9ce57c Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Wed, 7 Aug 2019 15:38:11 -0400 Subject: [PATCH 122/499] Use new KC shadow models for object-level perms --- kpi/deployment_backends/kc_access/shadow_models.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index aa5cac6feb..1ad9766aef 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -289,8 +289,13 @@ class KobocatUserObjectPermission(ShadowModel): CAVEAT LECTOR: The django-guardian custom manager, UserObjectPermissionManager, is NOT included! """ +<<<<<<< HEAD permission = models.ForeignKey(KobocatPermission, on_delete=models.CASCADE) content_type = models.ForeignKey(KobocatContentType, on_delete=models.CASCADE) +======= + permission = models.ForeignKey(KobocatPermission) + content_type = models.ForeignKey(KobocatContentType) +>>>>>>> Use new KC shadow models for object-level perms object_pk = models.CharField(_('object ID'), max_length=255) content_object = GenericForeignKey(fk_field='object_pk') # It's okay not to use `KobocatUser` as long as PKs are synchronized @@ -447,6 +452,8 @@ class Meta(ShadowModel.Meta): @classmethod def sync(cls, auth_user): + # NB: `KobocatUserObjectPermission` (and probably other things) depend + # upon PKs being synchronized between KPI and KoBoCAT try: kc_auth_user = cls.objects.get(pk=auth_user.pk) assert kc_auth_user.username == auth_user.username From 9ed993f55022cb9e56b23c03af3f985deb300cba Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Tue, 13 Aug 2019 12:14:42 -0400 Subject: [PATCH 123/499] Move model-level permission utils to separate file --- kpi/model_utils.py | 1 - kpi/signals.py | 94 ++++++++++++++++++++++++++++++++++++++++ kpi/utils/permissions.py | 1 - 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 kpi/signals.py diff --git a/kpi/model_utils.py b/kpi/model_utils.py index 131f03b3df..e55fb8876a 100644 --- a/kpi/model_utils.py +++ b/kpi/model_utils.py @@ -127,7 +127,6 @@ def create_assets(kls, structure, **options): obj = Asset.objects.create(**structure) return obj - @contextlib.contextmanager def disable_auto_field_update(kls, field_name): field = [f for f in kls._meta.fields if f.name == field_name][0] diff --git a/kpi/signals.py b/kpi/signals.py new file mode 100644 index 0000000000..fb4d2d182f --- /dev/null +++ b/kpi/signals.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from django.conf import settings +from django.contrib.auth.models import User +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from rest_framework.authtoken.models import Token +from taggit.models import Tag + +from kobo.apps.hook.models.hook import Hook +from kpi.deployment_backends.kc_access.shadow_models import ( + KobocatToken, + KobocatUser, +) +from kpi.models import Asset, Collection, ObjectPermission, TagUid +from kpi.utils.permissions import grant_default_model_level_perms + + +@receiver(post_save, sender=User) +def default_permissions_post_save(sender, instance, created, raw, **kwargs): + """ + Users must have both model-level and object-level permissions to satisfy + DRF, so assign the newly-created user all available collection and asset + permissions at the model level + """ + if raw: + # `raw` means we can't touch (so make sure your fixtures include + # all necessary permissions!) + return + if not created: + # We should only grant default permissions when the user is first + # created + return + grant_default_model_level_perms(instance) + + +@receiver(post_save, sender=User) +def save_kobocat_user(sender, instance, **kwargs): + """ + Sync Auth User table between KPI and KC + """ + if not settings.TESTING: + KobocatUser.sync(instance) + + +@receiver(post_save, sender=Token) +def save_kobocat_token(sender, instance, **kwargs): + """ + Sync AuthToken table between KPI and KC + """ + if not settings.TESTING: + KobocatToken.sync(instance) + + +@receiver(post_delete, sender=Token) +def delete_kobocat_token(sender, instance, **kwargs): + """ + Delete corresponding record from KC AuthToken table + """ + if not settings.TESTING: + KobocatToken.objects.filter(pk=instance.pk).delete() + + +@receiver(post_save, sender=Tag) +def tag_uid_post_save(sender, instance, created, raw, **kwargs): + """ Make sure we have a TagUid object for each newly-created Tag """ + if raw or not created: + return + TagUid.objects.get_or_create(tag=instance) + + +@receiver([post_save, post_delete], sender=Hook) +def update_kc_xform_has_kpi_hooks(sender, instance, **kwargs): + """ + Updates `kc.XForm` instance as soon as Asset.Hook list is updated. + """ + asset = instance.asset + if asset.has_deployment: + asset.deployment.set_has_kpi_hooks() + + +@receiver(post_delete, sender=Collection) +def post_delete_collection(sender, instance, **kwargs): + # Remove all permissions associated with this object + ObjectPermission.objects.filter_for_object(instance).delete() + # No recalculation is necessary since children will also be deleted + + +@receiver(post_delete, sender=Asset) +def post_delete_asset(sender, instance, **kwargs): + # Remove all permissions associated with this object + ObjectPermission.objects.filter_for_object(instance).delete() + # No recalculation is necessary since children will also be deleted diff --git a/kpi/utils/permissions.py b/kpi/utils/permissions.py index e5a1dd83e0..c2cae77ea2 100644 --- a/kpi/utils/permissions.py +++ b/kpi/utils/permissions.py @@ -13,7 +13,6 @@ in sys.modules and nothing else. (https://stackoverflow.com/a/4177780) """ - def grant_default_model_level_perms(user): """ Gives `user` unrestricted model-level access to Collections and Assets. From 4374272632866d195c5cd62f67939e8f33725635 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Tue, 13 Aug 2019 12:20:04 -0400 Subject: [PATCH 124/499] Assign KC model-level perms using shadow models --- .../kc_access/shadow_models.py | 13 ++++++++----- kpi/deployment_backends/kc_access/utils.py | 6 ++++++ .../commands/grant_default_permissions.py | 3 +++ kpi/signals.py | 15 +++++++++++++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index 1ad9766aef..7cf0c36f7a 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -289,13 +289,8 @@ class KobocatUserObjectPermission(ShadowModel): CAVEAT LECTOR: The django-guardian custom manager, UserObjectPermissionManager, is NOT included! """ -<<<<<<< HEAD permission = models.ForeignKey(KobocatPermission, on_delete=models.CASCADE) content_type = models.ForeignKey(KobocatContentType, on_delete=models.CASCADE) -======= - permission = models.ForeignKey(KobocatPermission) - content_type = models.ForeignKey(KobocatContentType) ->>>>>>> Use new KC shadow models for object-level perms object_pk = models.CharField(_('object ID'), max_length=255) content_object = GenericForeignKey(fk_field='object_pk') # It's okay not to use `KobocatUser` as long as PKs are synchronized @@ -434,6 +429,14 @@ def sync(cls, user): ) +class KobocatUserPermission(ShadowModel): + """ Needed to assign model-level KoBoCAT permissions """ + user = models.ForeignKey('KobocatUser', db_column='user_id') + permission = models.ForeignKey('KobocatPermission', db_column='permission_id') + class Meta(ShadowModel.Meta): + db_table = 'auth_user_user_permissions' + + class KobocatUser(ShadowModel): username = models.CharField(_("username"), max_length=30) diff --git a/kpi/deployment_backends/kc_access/utils.py b/kpi/deployment_backends/kc_access/utils.py index 617510d7bd..a6795b29a5 100644 --- a/kpi/deployment_backends/kc_access/utils.py +++ b/kpi/deployment_backends/kc_access/utils.py @@ -237,6 +237,7 @@ def grant_kc_model_level_perms(user): 'Searched for content types {}.'.format(content_types) ) +<<<<<<< HEAD # What KC permissions does this user already have? Getting the KC database # column names right necessitated a custom M2M model, # `KobocatUserPermission`, which means we can't use Django's tolerant @@ -250,6 +251,11 @@ def grant_kc_model_level_perms(user): KobocatUserPermission.objects.bulk_create([ KobocatUserPermission(user=user, permission=p) for p in permissions_to_assign if p.pk not in existing_user_perm_pks +======= + KobocatUserPermission.objects.bulk_create([ + KobocatUserPermission(user=user, permission=p) + for p in permissions_to_assign +>>>>>>> Assign KC model-level perms using shadow models ]) diff --git a/kpi/management/commands/grant_default_permissions.py b/kpi/management/commands/grant_default_permissions.py index 9b103cc7b8..16ed4a4126 100644 --- a/kpi/management/commands/grant_default_permissions.py +++ b/kpi/management/commands/grant_default_permissions.py @@ -4,7 +4,10 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User +<<<<<<< HEAD +======= +>>>>>>> Assign KC model-level perms using shadow models from kpi.deployment_backends.kc_access.utils import grant_kc_model_level_perms from kpi.utils.permissions import grant_default_model_level_perms diff --git a/kpi/signals.py b/kpi/signals.py index fb4d2d182f..26dcf359a0 100644 --- a/kpi/signals.py +++ b/kpi/signals.py @@ -13,6 +13,7 @@ KobocatToken, KobocatUser, ) +from kpi.deployment_backends.kc_access.utils import grant_kc_model_level_perms from kpi.models import Asset, Collection, ObjectPermission, TagUid from kpi.utils.permissions import grant_default_model_level_perms @@ -36,12 +37,22 @@ def default_permissions_post_save(sender, instance, created, raw, **kwargs): @receiver(post_save, sender=User) -def save_kobocat_user(sender, instance, **kwargs): +def save_kobocat_user(sender, instance, created, raw, **kwargs): """ - Sync Auth User table between KPI and KC + Sync auth_user table between KPI and KC, and, if the user is newly created, + grant all KoBoCAT model-level permissions for the content types listed in + `settings.KOBOCAT_DEFAULT_PERMISSION_CONTENT_TYPES` """ if not settings.TESTING: KobocatUser.sync(instance) + if created: + # FIXME: If this fails, the next attempt results in + # IntegrityError: duplicate key value violates unique constraint + # "auth_user_username_key" + # and decorating this function with `transaction.atomic` doesn't + # seem to help. We should roll back the KC user creation if + # assigning model-level permissions fails + grant_kc_model_level_perms(instance) @receiver(post_save, sender=Token) From ee5268f571865d28ca61f70d01a22f91c726eaeb Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Tue, 13 Aug 2019 13:14:03 -0400 Subject: [PATCH 125/499] Don't add KC model-level perms that already exist --- kpi/deployment_backends/kc_access/utils.py | 52 ++++++++++++++++------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/kpi/deployment_backends/kc_access/utils.py b/kpi/deployment_backends/kc_access/utils.py index a6795b29a5..17ab8bade5 100644 --- a/kpi/deployment_backends/kc_access/utils.py +++ b/kpi/deployment_backends/kc_access/utils.py @@ -5,17 +5,18 @@ import requests from django.conf import settings from django.contrib.auth.models import User -from django.db import ProgrammingError, transaction -from rest_framework.authtoken.models import Token -import requests from django.core.exceptions import ImproperlyConfigured +from django.db import IntegrityError, ProgrammingError, transaction +from rest_framework.authtoken.models import Token from kpi.exceptions import KobocatProfileException from kpi.utils.log import logging from .shadow_models import ( safe_kc_read, KobocatContentType, + KobocatDigestPartial, KobocatPermission, + KobocatToken, KobocatUser, KobocatUserObjectPermission, KobocatUserPermission, @@ -29,9 +30,8 @@ def _trigger_kc_profile_creation(user): Get the user's profile via the KC API, causing KC to create a KC UserProfile if none exists already """ - url = settings.KOBOCAT_URL + '/api/v1/user' - kobo_user = User.objects.using('kobocat').get(username=user.username) - token = Token.objects.using('kobocat').get(user=kobo_user) + url = settings.KOBOCAT_INTERNAL_URL + '/api/v1/user' + token, _ = Token.objects.get_or_create(user=user) response = requests.get( url, headers={'Authorization': 'Token ' + token.key}) if not response.status_code == 200: @@ -117,7 +117,6 @@ def set_kc_require_auth(user_id, require_auth): Configure whether or not authentication is required to see and submit data to a user's projects. WRITES to KobocatUserProfile.require_auth - :param int user_id: ID/primary key of the :py:class:`User` object. :param bool require_auth: The desired setting. """ @@ -237,7 +236,6 @@ def grant_kc_model_level_perms(user): 'Searched for content types {}.'.format(content_types) ) -<<<<<<< HEAD # What KC permissions does this user already have? Getting the KC database # column names right necessitated a custom M2M model, # `KobocatUserPermission`, which means we can't use Django's tolerant @@ -251,11 +249,6 @@ def grant_kc_model_level_perms(user): KobocatUserPermission.objects.bulk_create([ KobocatUserPermission(user=user, permission=p) for p in permissions_to_assign if p.pk not in existing_user_perm_pks -======= - KobocatUserPermission.objects.bulk_create([ - KobocatUserPermission(user=user, permission=p) - for p in permissions_to_assign ->>>>>>> Assign KC model-level perms using shadow models ]) @@ -366,3 +359,36 @@ def remove_applicable_kc_permissions(obj, user, kpi_codenames): # `permission` has a FK to `ContentType`, but I'm paranoid **content_type_kwargs ).delete() + + +def delete_kc_users(deleted_pks: list) -> bool: + """ + Args: + deleted_pks: List of primary keys of KPI deleted objects + Returns: + bool: whether it has succeeded or not. + """ + + # Then, delete users in KoBoCAT database + # Post signal is not triggered because the + # deletion is made at the model level, not the object level + kc_models = [ + KobocatDigestPartial, + KobocatUserPermission, + KobocatUserProfile, + KobocatUserObjectPermission, + KobocatToken, + ] + # We can delete related objects + for kc_model in kc_models: + kc_model.objects.filter(user_id__in=deleted_pks).delete() + + try: + # If users have projects/submissions, this query should fail with + # an `IntegrityError`. + KobocatUser.objects.filter(id__in=deleted_pks).delete() + except IntegrityError as e: + logging.error(e) + return False + + return True From e303e7fa63a05291911c10ef168416f66fa3fe3f Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Sat, 17 Aug 2019 10:18:54 -0400 Subject: [PATCH 126/499] Reorganized some imports and applied few PEP-8 rules --- kpi/deployment_backends/kc_access/shadow_models.py | 1 + kpi/management/commands/grant_default_permissions.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index 7cf0c36f7a..eb666f0070 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -433,6 +433,7 @@ class KobocatUserPermission(ShadowModel): """ Needed to assign model-level KoBoCAT permissions """ user = models.ForeignKey('KobocatUser', db_column='user_id') permission = models.ForeignKey('KobocatPermission', db_column='permission_id') + class Meta(ShadowModel.Meta): db_table = 'auth_user_user_permissions' diff --git a/kpi/management/commands/grant_default_permissions.py b/kpi/management/commands/grant_default_permissions.py index 16ed4a4126..3776904fda 100644 --- a/kpi/management/commands/grant_default_permissions.py +++ b/kpi/management/commands/grant_default_permissions.py @@ -4,10 +4,6 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -<<<<<<< HEAD - -======= ->>>>>>> Assign KC model-level perms using shadow models from kpi.deployment_backends.kc_access.utils import grant_kc_model_level_perms from kpi.utils.permissions import grant_default_model_level_perms From 8cf414da033ea1f5b7c22667ae14d89ba6d81e7d Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Wed, 28 Aug 2019 12:44:04 +0200 Subject: [PATCH 127/499] fix password reset link --- jsapp/js/components/accountSettings.es6 | 150 ++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/jsapp/js/components/accountSettings.es6 b/jsapp/js/components/accountSettings.es6 index 40d5924188..ea6e9b4b1f 100644 --- a/jsapp/js/components/accountSettings.es6 +++ b/jsapp/js/components/accountSettings.es6 @@ -20,6 +20,7 @@ import { stringToColor, } from '../utils'; import {WEB_PAGE_TITLE} from '../config' +import {ROOT_URL} from './constants'; const UNSAVED_CHANGES_WARNING = t('You have unsaved changes. Leave settings without saving?'); @@ -501,3 +502,152 @@ export default class AccountSettings extends React.Component { reactMixin(AccountSettings.prototype, Reflux.connect(stores.session, 'session')); reactMixin(AccountSettings.prototype, Reflux.ListenerMixin); + +export class ChangePassword extends React.Component { + constructor(props) { + super(props); + this.errors = {}; + this.state = { + errors: this.errors, + currentPassword: '', + newPassword: '', + verifyPassword: '' + }; + autoBind(this); + } + + componentDidMount() { + this.listenTo(actions.auth.changePassword.failed, this.changePasswordFailed); + } + + validateRequired(what) { + if (!this.state[what]) { + this.errors[what] = t('This field is required.'); + } + } + + changePassword() { + this.errors = {}; + this.validateRequired('currentPassword'); + this.validateRequired('newPassword'); + this.validateRequired('verifyPassword'); + if (this.state.newPassword != this.state.verifyPassword) { + this.errors['newPassword'] = t('This field must match the Verify Password field.'); + } + if (Object.keys(this.errors).length === 0) { + actions.auth.changePassword(this.state.currentPassword, this.state.newPassword); + } + this.setState({errors: this.errors}); + } + + changePasswordFailed(jqXHR) { + if (jqXHR.responseJSON.current_password) { + this.errors.currentPassword = jqXHR.responseJSON.current_password; + } + if (jqXHR.responseJSON.new_password) { + this.errors.newPassword = jqXHR.responseJSON.new_password; + } + this.setState({errors: this.errors}); + } + + currentPasswordChange(val) { + this.setState({currentPassword: val}); + } + + newPasswordChange(val) { + this.setState({newPassword: val}); + } + + verifyPasswordChange(val) { + this.setState({verifyPassword: val}); + } + + render() { + if(!stores.session || !stores.session.currentAccount) { + return ( + + + + + + + {t('loading...')} + + + + + + ); + } + + var accountName = stores.session.currentAccount.username; + var initialsStyle = { + background: `#${stringToColor(accountName)}` + }; + + return ( + + + + + + {accountName.charAt(0)} + +

{accountName}

+
+ + +

{t('Reset Password')}

+
+ + + + + + {t('Forgot Password?')} + + + + + + + + + + + + + + +
+
+
+ ); + } +}; + +reactMixin(ChangePassword.prototype, Reflux.connect(stores.session, 'session')); +reactMixin(ChangePassword.prototype, Reflux.ListenerMixin); From 97aa9266a0503e97e4a07ed04d9e6e1db63447cc Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Sat, 24 Aug 2019 01:57:48 -0400 Subject: [PATCH 128/499] =?UTF-8?q?Add=20`label`=20to=20`AssetPermissionSe?= =?UTF-8?q?rializer`=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit that respects the `asset_type`. Remove `name` from `PermissionSerializer` and change `description` to `name` to match Django convention --- kpi/models/asset.py | 29 ++++++++++++++++++++++---- kpi/serializers/v2/asset_permission.py | 5 +++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 2e0c3135a4..2d845c8bb5 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -609,6 +609,7 @@ class Meta: PERM_MANAGE_ASSET, ), } + ASSIGNABLE_PERMISSIONS = tuple(ASSIGNABLE_PERMISSIONS_WITH_LABELS.keys()) # Calculated permissions that are neither directly assignable nor stored # in the database, but instead implied by assignable permissions @@ -737,6 +738,30 @@ def create_version(self) -> [AssetVersion, None]: deployed=False, ) + def get_label_for_permission(self, permission_or_codename): + try: + codename = permission_or_codename.codename + permission = permission_or_codename + except AttributeError: + codename = permission_or_codename + permission = None + try: + label = self.ASSIGNABLE_PERMISSIONS_WITH_LABELS[codename] + except KeyError: + if not permission: + # Seems expensive. Cache it? + permission = Permission.objects.filter( + content_type=ContentType.objects.get_for_model(self), + codename=codename + ) + label = permission.name + label = label.replace( + '##asset_type_label##', + # Raises TypeError if not coerced explicitly + six.text_type(self.ASSET_TYPE_LABELS[self.asset_type]) + ) + return label + @property def deployed_versions(self): return self.asset_versions.filter(deployed=True).order_by( @@ -825,10 +850,6 @@ def get_partial_perms( ``` ['view_submissions',] ``` -<<<<<<< HEAD -======= - ->>>>>>> Applied requested changes (typos, better readability python code) as per discussed with jnm `get_partial_perms(user1_obj.id, with_filters=True)` would return ``` { diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 0fead3be3d..1e23b327b0 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -40,10 +40,11 @@ class Meta: 'url', 'user', 'permission', - 'partial_permissions' + 'partial_permissions', + 'label', ) - read_only_fields = ('uid', ) + read_only_fields = ('uid', 'label') def create(self, validated_data): user = validated_data['user'] From da4e81bb5270dd99aaca7699d5b1d790de0f2c87 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Wed, 28 Aug 2019 20:32:56 -0400 Subject: [PATCH 129/499] Alphabetize methods of `Asset` --- kpi/models/asset.py | 47 +++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 2d845c8bb5..9bc0d23892 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -738,6 +738,36 @@ def create_version(self) -> [AssetVersion, None]: deployed=False, ) + @property + def deployed_versions(self): + return self.asset_versions.filter(deployed=True).order_by( + '-date_modified') + + def get_ancestors_or_none(self): + # ancestors are ordered from farthest to nearest + if self.parent is not None: + return self.parent.get_ancestors(include_self=True) + else: + return None + + def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): + """ + Returns the list of filters for a specific permission `perm` + and this specific asset. + :param user_id: + :param perm: see `constants.*_SUBMISSIONS` + :return: + """ + if not perm.endswith(SUFFIX_SUBMISSIONS_PERMS) or perm == PERM_PARTIAL_SUBMISSIONS: + raise BadPermissionsException(_('Only partial permissions for ' + 'submissions are supported')) + + perms = self.get_partial_perms(user_id, with_filters=True) + if perms: + return perms.get(perm) + return None + + def get_label_for_permission(self, permission_or_codename): try: codename = permission_or_codename.codename @@ -871,23 +901,6 @@ def get_partial_perms( else: return list(perms) - def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): - """ - Returns the list of filters for a specific permission `perm` - and this specific asset. - :param user_id: - :param perm: see `constants.*_SUBMISSIONS` - :return: - """ - if not perm.endswith(SUFFIX_SUBMISSIONS_PERMS) or perm == PERM_PARTIAL_SUBMISSIONS: - raise BadPermissionsException(_('Only partial permissions for ' - 'submissions are supported')) - - perms = self.get_partial_perms(user_id, with_filters=True) - if perms: - return perms.get(perm) - return None - @property def has_active_hooks(self): """ From cd957139d55a84ff3d199bc94ea6436f9ed59c41 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Mon, 5 Aug 2019 12:15:31 -0400 Subject: [PATCH 130/499] Reorganized imports --- kpi/deployment_backends/kc_access/utils.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/kpi/deployment_backends/kc_access/utils.py b/kpi/deployment_backends/kc_access/utils.py index 17ab8bade5..449c5a8a07 100644 --- a/kpi/deployment_backends/kc_access/utils.py +++ b/kpi/deployment_backends/kc_access/utils.py @@ -1,12 +1,24 @@ +<<<<<<< HEAD # coding: utf-8 +======= +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +>>>>>>> Reorganized imports import json import logging import requests from django.conf import settings +<<<<<<< HEAD from django.contrib.auth.models import User from django.core.exceptions import ImproperlyConfigured from django.db import IntegrityError, ProgrammingError, transaction +======= +from django.contrib.auth.models import User, Permission +from django.contrib.contenttypes.models import ContentType +from django.db import ProgrammingError, transaction +>>>>>>> Reorganized imports from rest_framework.authtoken.models import Token from kpi.exceptions import KobocatProfileException From a28f41e96009e49672c4b341647cea73c060017a Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Mon, 5 Aug 2019 12:18:39 -0400 Subject: [PATCH 131/499] Assign KC perms on the first deployment if asset has other permissions than owner's ones --- kpi/deployment_backends/kobocat_backend.py | 18 ++++++++++++++++++ kpi/deployment_backends/mock_backend.py | 3 +++ 2 files changed, 21 insertions(+) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index b0fb42fd4c..88a3fdf28b 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -49,6 +49,24 @@ def bulk_assign_mapped_perms(self): continue assign_applicable_kc_permissions(self.asset, user, perms) + def bulk_assign_mapped_perms(self): + """ + Bulk assign all `kc` permissions related to `kpi` permissions. + Useful to assign permissions retroactively upon deployment. + Beware: it only adds permissions, it does not remove or sync permissions. + """ + users_with_perms = self.asset.get_users_with_perms(attach_perms=True) + + # if only the owner has permissions, no need to go further + if len(users_with_perms) == 1 and \ + users_with_perms.keys()[0].id == self.asset.owner_id: + return + + for user, perms in users_with_perms.items(): + if user.id == self.asset.owner_id: + continue + assign_applicable_kc_permissions(self.asset, user, perms) + @staticmethod def make_identifier(username, id_string): """ Uses `settings.KOBOCAT_URL` to construct an identifier from a diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py index f765c0e057..3377215fed 100644 --- a/kpi/deployment_backends/mock_backend.py +++ b/kpi/deployment_backends/mock_backend.py @@ -17,6 +17,9 @@ class MockDeploymentBackend(BaseDeploymentBackend): # TODO. Stop using protected property `_deployment_data`. """ + def bulk_assign_mapped_perms(self): + pass + def bulk_assign_mapped_perms(self): pass From 80b50da7508e8d40c296348f6b3a1b4890d47853 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 3 Sep 2019 18:00:16 -0400 Subject: [PATCH 132/499] Init "partial_permissions" to None by default when validating partial permissions in serializer --- kpi/serializers/v2/asset_permission.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py index 1e23b327b0..50f9bc918e 100644 --- a/kpi/serializers/v2/asset_permission.py +++ b/kpi/serializers/v2/asset_permission.py @@ -113,9 +113,11 @@ def _invalid_partial_permissions(message): ) request = self.context['request'] - if isinstance(request.data, dict): # for a single assignment + partial_permissions = None + + if isinstance(request.data, dict): # for a single assignment partial_permissions = request.data.get('partial_permissions') - elif self.context.get('partial_permissions'): # injected during bulk assignment + elif self.context.get('partial_permissions'): # injected during bulk assignment partial_permissions = self.context.get('partial_permissions') if not partial_permissions: From e586b8298f6ba2fb9a3df3b3ed4224933ad30fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 4 Sep 2019 08:48:58 -0400 Subject: [PATCH 133/499] Tmp fix to load v2 asset's snapshot endpoint when asset identity link uses v2 as well --- jsapp/js/dataInterface.es6 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index abac3107a0..4058dde245 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -89,6 +89,16 @@ export var dataInterface; return $.getJSON(`${ROOT_URL}/api/v2/collections/?all_public=true`); }, createAssetSnapshot (data) { + // Temporary fix + // ToDo when PR#2378 is merged, remove logic and + // use `${ROOT_URL}/api/v2/asset_snapshots/` directly + let v2Prefix = '/api/v2', + url = `${ROOT_URL}/asset_snapshots/`; + + if ('asset' in data && data['asset'].indexOf(`${ROOT_URL}${v2Prefix}`) === 0) { + url = `${ROOT_URL}${v2Prefix}/asset_snapshots/` + } + return $ajax({ url: `${ROOT_URL}/api/v2/asset_snapshots/`, method: 'POST', From e1409a2eaf9674ec724d252a6772c37a3fd4082c Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 25 Sep 2018 11:21:06 +0200 Subject: [PATCH 134/499] redesign bulk selection dropdown and add delete option --- jsapp/js/components/table.es6 | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index 0d1c792dd6..71efa82353 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -961,6 +961,25 @@ export class DataTable extends React.Component { } } + + {Object.keys(selected).length > 0 && + + {VALIDATION_STATUSES.map((item, n) => { + return ( + + {t('Set status: ##status##').replace('##status##', item.label)} + + ); + })} + + {t('Delete selected')} + + + } ); } From b6577a3449f9f0c78e96ec45d79e09e9025afc77 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 16 Jul 2019 12:02:18 -0400 Subject: [PATCH 135/499] Removed redundant code from merge with 'master' branch --- jsapp/js/components/table.es6 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index 71efa82353..980d7b93c3 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -961,10 +961,11 @@ export class DataTable extends React.Component { } } + */ {Object.keys(selected).length > 0 && - {VALIDATION_STATUSES.map((item, n) => { + {VALIDATION_STATUSES_LIST.map((item, n) => { return ( Date: Tue, 16 Jul 2019 16:30:18 -0400 Subject: [PATCH 136/499] Added JS code to make calls to BE on data bulk delete --- jsapp/js/components/table.es6 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index 980d7b93c3..131320452e 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -968,7 +968,7 @@ export class DataTable extends React.Component { {VALIDATION_STATUSES_LIST.map((item, n) => { return ( @@ -976,7 +976,8 @@ export class DataTable extends React.Component { ); })} - + {t('Delete selected')} From 0445ad417b1c2241e9403fa39dff81ce36cb3e14 Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 16 Jul 2019 17:02:50 -0400 Subject: [PATCH 137/499] Dummy response for FE on bulk deleted --- kpi/views/v2/data.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/kpi/views/v2/data.py b/kpi/views/v2/data.py index 8db87cd227..fb8b0af1e3 100644 --- a/kpi/views/v2/data.py +++ b/kpi/views/v2/data.py @@ -2,9 +2,14 @@ from django.conf import settings from django.http import Http404 from django.utils.translation import ugettext_lazy as _ +<<<<<<< HEAD from rest_framework import renderers, serializers, viewsets from rest_framework.decorators import action from rest_framework.pagination import _positive_int as positive_int +======= +from rest_framework import renderers, viewsets, status +from rest_framework.decorators import detail_route, list_route +>>>>>>> Dummy response for FE on bulk deleted from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin @@ -219,11 +224,22 @@ def _get_deployment(self): _('The specified asset has not been deployed')) return self.asset.deployment +<<<<<<< HEAD @action(detail=False, methods=['DELETE'], renderer_classes=[renderers.JSONRenderer]) def bulk(self, request, *args, **kwargs): deployment = self._get_deployment() json_response = deployment.delete_submissions(request.data, request.user) +======= + @list_route(methods=['DELETE'], renderer_classes=[renderers.JSONRenderer]) + def bulk(self, request, *args, **kwargs): + # WIP - returns a dummy empty reponse. + # ToDo + json_response = { + 'data': '', + 'status': status.HTTP_204_NO_CONTENT + } +>>>>>>> Dummy response for FE on bulk deleted return Response(**json_response) def destroy(self, request, *args, **kwargs): From f0a51fa3f18a983034889a5ab3f49d9159cfba59 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 19 Jul 2019 12:42:40 +0200 Subject: [PATCH 138/499] keep dialog open until response arrives plus some cleanup --- kpi/views/v2/data.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/kpi/views/v2/data.py b/kpi/views/v2/data.py index fb8b0af1e3..8db87cd227 100644 --- a/kpi/views/v2/data.py +++ b/kpi/views/v2/data.py @@ -2,14 +2,9 @@ from django.conf import settings from django.http import Http404 from django.utils.translation import ugettext_lazy as _ -<<<<<<< HEAD from rest_framework import renderers, serializers, viewsets from rest_framework.decorators import action from rest_framework.pagination import _positive_int as positive_int -======= -from rest_framework import renderers, viewsets, status -from rest_framework.decorators import detail_route, list_route ->>>>>>> Dummy response for FE on bulk deleted from rest_framework.response import Response from rest_framework_extensions.mixins import NestedViewSetMixin @@ -224,22 +219,11 @@ def _get_deployment(self): _('The specified asset has not been deployed')) return self.asset.deployment -<<<<<<< HEAD @action(detail=False, methods=['DELETE'], renderer_classes=[renderers.JSONRenderer]) def bulk(self, request, *args, **kwargs): deployment = self._get_deployment() json_response = deployment.delete_submissions(request.data, request.user) -======= - @list_route(methods=['DELETE'], renderer_classes=[renderers.JSONRenderer]) - def bulk(self, request, *args, **kwargs): - # WIP - returns a dummy empty reponse. - # ToDo - json_response = { - 'data': '', - 'status': status.HTTP_204_NO_CONTENT - } ->>>>>>> Dummy response for FE on bulk deleted return Response(**json_response) def destroy(self, request, *args, **kwargs): From 168a927d0d5d67829d960cb8f112fd59fb9f912a Mon Sep 17 00:00:00 2001 From: Raphaelle Date: Mon, 26 Aug 2019 16:50:25 -0400 Subject: [PATCH 139/499] Conditional choices in dropdown depending on permissions --- jsapp/js/components/table.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index 131320452e..aa8a56c7de 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -980,6 +980,7 @@ export class DataTable extends React.Component { onClick={this.onBulkDelete}> {t('Delete selected')} + } } From 0154c9da88d7153777cc19ca4b29a1482f66cf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 4 Sep 2019 11:18:51 -0400 Subject: [PATCH 140/499] Renamed 'submissions_ids' and 'instances_ids' to 'submission_ids' and 'instance_ids' --- jsapp/js/components/table.es6 | 8 ++++---- kpi/deployment_backends/mock_backend.py | 2 +- kpi/utils/mongo_helper.py | 2 +- kpi/views/v2/data.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index aa8a56c7de..615016e19c 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -801,9 +801,9 @@ export class DataTable extends React.Component { } selectedCount = this.state.resultsTotal; } else { - data.submissions_ids = Object.keys(this.state.selectedRows); + data.submission_ids = Object.keys(this.state.selectedRows); data['validation_status.uid'] = val; - selectedCount = data.submissions_ids.length; + selectedCount = data.submission_ids.length; } const dialog = alertify.dialog('confirm'); @@ -849,8 +849,8 @@ export class DataTable extends React.Component { } selectedCount = this.state.resultsTotal; } else { - data.submissions_ids = Object.keys(this.state.selectedRows); - selectedCount = data.submissions_ids.length; + data.submission_ids = Object.keys(this.state.selectedRows); + selectedCount = data.submission_ids.length; } let msg, onshow; msg = t('You are about to permanently delete ##count## data entries.').replace('##count##', selectedCount); diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py index 3377215fed..81ed955a0a 100644 --- a/kpi/deployment_backends/mock_backend.py +++ b/kpi/deployment_backends/mock_backend.py @@ -170,7 +170,7 @@ def get_submissions(self, requesting_user_id, **kwargs) permission_filters = params['permission_filters'] - if len(instances_ids) > 0: + if len(instance_ids) > 0: if format_type == INSTANCE_FORMAT_TYPE_XML: instance_ids = [str(instance_id) for instance_id in instance_ids] # ugly way to find matches, but it avoids to load each xml in memory. diff --git a/kpi/utils/mongo_helper.py b/kpi/utils/mongo_helper.py index 60cd154aae..356a76110e 100644 --- a/kpi/utils/mongo_helper.py +++ b/kpi/utils/mongo_helper.py @@ -81,7 +81,7 @@ def get_count( @classmethod def get_instances( cls, mongo_userform_id, hide_deleted=True, start=None, limit=None, - sort=None, fields=None, query=None, instances_ids=None, + sort=None, fields=None, query=None, instance_ids=None, permission_filters=None ): cursor, total_count = cls._get_cursor_and_count( diff --git a/kpi/views/v2/data.py b/kpi/views/v2/data.py index 8db87cd227..6ce3f26d61 100644 --- a/kpi/views/v2/data.py +++ b/kpi/views/v2/data.py @@ -193,7 +193,7 @@ class DataViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, > **Payload** > > { - > "submissions_ids": [{integer}], + > "submission_ids": [{integer}], > "validation_status.uid": > } From 9435399e6cfd4cf9f039d0924d27d05ffb5af572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 4 Sep 2019 12:28:03 -0400 Subject: [PATCH 141/499] Removed useless param sent to KoBoCat when bulk validate statuses --- kpi/deployment_backends/kobocat_backend.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index 88a3fdf28b..9a046583a1 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -647,9 +647,6 @@ def set_validation_statuses(self, data, user, method): url = self.submission_list_url data = data.copy() # Need to get a copy to update the dict - if method == 'DELETE': - data['reset'] = True - # `PATCH` KC even if kpi receives `DELETE` kc_request = requests.Request(method='PATCH', url=url, json=data) kc_response = self.__kobocat_proxy_request(kc_request, user) From 46489c44d8f397798b1f3137d72bd2643ae03d47 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Wed, 4 Sep 2019 20:20:44 -0400 Subject: [PATCH 142/499] Fix a few typos causing front-end tests to fail (but there are still more failures to fix) --- jsapp/js/components/permissions/permissionsMocks.es6 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jsapp/js/components/permissions/permissionsMocks.es6 b/jsapp/js/components/permissions/permissionsMocks.es6 index 72b74efb30..0b56865a96 100644 --- a/jsapp/js/components/permissions/permissionsMocks.es6 +++ b/jsapp/js/components/permissions/permissionsMocks.es6 @@ -192,7 +192,11 @@ const assetWithMultipleUsers = { 'permission': '/api/v2/permissions/view_submissions/' }, { +<<<<<<< HEAD 'url': '/api/v2/assets/arMB2dNgwewktv954wmo9e/permission-assignments/pETvxGayAJwvPaCnt5biVD/', +======= + 'url': '/api/v2/assets/arMB2dNgwewktv954wmo9e/permissions/pETvxGayAJwvPaCnt5biVD/', +>>>>>>> Fix a few typos causing front-end tests to fail 'user': '/api/v2/users/olivier/', 'permission': '/api/v2/permissions/view_asset/' }, From 21a87bc843df9d2ab290ecf4f7d600c6931d1b37 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 6 Sep 2019 22:52:18 +0200 Subject: [PATCH 143/499] add-fix test for parseUserWithPermsList and partial --- .../permissions/permParser.tests.es6 | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/jsapp/js/components/permissions/permParser.tests.es6 b/jsapp/js/components/permissions/permParser.tests.es6 index ef175203fb..e3b3ca430a 100644 --- a/jsapp/js/components/permissions/permParser.tests.es6 +++ b/jsapp/js/components/permissions/permParser.tests.es6 @@ -403,5 +403,56 @@ describe('permParser', () => { }, ]); }); + + it('should not omit partial permissions', () => { + const userWithPermsList = permParser.parseBackendData( + endpoints.assetWithPartial.results, + endpoints.assetWithPartial.results[0].user + ); + const parsed = permParser.parseUserWithPermsList(userWithPermsList); + + chai.expect(parsed).to.deep.equal([ + { + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/add_submissions/' + }, + { + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/change_asset/' + }, + { + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/change_submissions/' + }, + { + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/validate_submissions/' + }, + { + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/view_asset/' + }, + { + 'user': '/api/v2/users/kobo/', + 'permission': '/api/v2/permissions/view_submissions/' + }, + { + 'user': '/api/v2/users/leszek/', + 'permission': '/api/v2/permissions/view_asset/' + }, + { + 'user': '/api/v2/users/leszek/', + 'permission': '/api/v2/permissions/partial_submissions/', + 'partial_permissions': [ + { + url: '/api/v2/permissions/view_submissions/', + filters: [ + {'_submitted_by': {'$in': ['john', 'olivier']}} + ] + } + ] + } + ]); + }); }); }); From be2a32bbcdf75f9a3b9e933f2e574844ef33f38d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Tue, 30 Jul 2019 10:14:55 -0400 Subject: [PATCH 144/499] Changed DataViewSet response and added pagination --- kpi/deployment_backends/kobocat_backend.py | 27 ++++++-- kpi/deployment_backends/mock_backend.py | 4 +- kpi/paginators.py | 71 ++++++++++++++++++++++ kpi/tests/api/v1/test_api_submissions.py | 3 + 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index 9a046583a1..954cbee6aa 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -563,9 +563,25 @@ def get_submission_validation_status_url(self, submission_pk): ) return url - def get_submissions(self, requesting_user_id, - format_type=INSTANCE_FORMAT_TYPE_JSON, - instance_ids=[], **kwargs): + def get_submission(self, pk, format_type=INSTANCE_FORMAT_TYPE_JSON, **kwargs): + """ + Returns only one occurrence. + + :param pk: int. `Instance.id` + :param format_type: str. INSTANCE_FORMAT_TYPE_JSON|INSTANCE_FORMAT_TYPE_XML + :param kwargs: dict. Filter params + :return: mixed. JSON or XML + """ + + if pk: + submissions = list(self.get_submissions(format_type, [int(pk)], **kwargs)) + if len(submissions) > 0: + return submissions[0] + return None + else: + raise ValueError(_('Primary key must be provided')) + + def get_submissions(self, format_type=INSTANCE_FORMAT_TYPE_JSON, instances_ids=[], **kwargs): """ Retrieves submissions through Postgres or Mongo depending on `format_type`. It can be filtered on instances ids. @@ -597,7 +613,10 @@ def get_submissions(self, requesting_user_id, raise BadFormatException( "The format {} is not supported".format(format_type) ) - return submissions_kobocat_request + return submissions + + def get_submissions_count(self, **kwargs): + pass def get_validation_status(self, submission_pk, params, user): url = self.get_submission_validation_status_url(submission_pk) diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py index 81ed955a0a..a5f72ba76e 100644 --- a/kpi/deployment_backends/mock_backend.py +++ b/kpi/deployment_backends/mock_backend.py @@ -209,7 +209,9 @@ def get_submissions(self, requesting_user_id, pass else: submissions = [submission for submission in submissions - if submission.get('submitted_by') in submitted_by] + if submission.get('_submitted_by') in submitted_by] + + self.current_submissions_count = len(submissions) return submissions diff --git a/kpi/paginators.py b/kpi/paginators.py index b7a2a18e30..e786a41a7b 100644 --- a/kpi/paginators.py +++ b/kpi/paginators.py @@ -6,6 +6,77 @@ PageNumberPagination, ) from rest_framework.reverse import reverse_lazy +from rest_framework.utils.urls import remove_query_param, replace_query_param + + +class DataPagination(PageNumberPagination): + """ + Pagination class for submissions. + Because DataViewSet doesn't provide a real Django ORM QuerySet and pagination + is based on `start` and `limit` querystring parameters, some methods of + PageNumberPagination had to be rewritten to support that case. + """ + page_size_query_param = 'limit' + start_query_param = 'start' + max_page_size = 500 + + def get_next_link(self): + if not self.page.has_next(): + return None + url = self.request.build_absolute_uri() + page_number = self.page.next_page_number() + return replace_query_param(url, self.start_query_param, + (page_number - 1) * self._page_size) + + def get_previous_link(self): + if not self.page.has_previous(): + return None + url = self.request.build_absolute_uri() + page_number = self.page.previous_page_number() + if page_number == 1: + return remove_query_param(url, self.start_query_param) + return replace_query_param(url, self.start_query_param, + (page_number - 1) * self._page_size) + + def get_start_value(self, request): + try: + return _positive_int( + request.query_params[self.start_query_param], + strict=True + ) + except (KeyError, ValueError): + pass + + return 0 + + def paginate_queryset(self, data, request, view=None): + """ + Paginate a queryset if required, either returning a + page object, or `None` if pagination is not configured for this view. + """ + page_size = self.get_page_size(request) + if not page_size: + return None + + paginator = self.django_paginator_class(data, page_size) + start = self.get_start_value(request) + page_number = int(start / page_size) + 1 + self._page_size = page_size + + try: + self.page = paginator.page(page_number) + except InvalidPage as exc: + msg = self.invalid_page_message.format( + page_number=page_number, message=str(exc) + ) + raise NotFound(msg) + + if paginator.num_pages > 1 and self.template is not None: + # The browsable API should display pagination controls. + self.display_page_controls = True + + self.request = request + return list(self.page) class DataPagination(LimitOffsetPagination): diff --git a/kpi/tests/api/v1/test_api_submissions.py b/kpi/tests/api/v1/test_api_submissions.py index 0148c250e6..bd003a590b 100644 --- a/kpi/tests/api/v1/test_api_submissions.py +++ b/kpi/tests/api/v1/test_api_submissions.py @@ -1,6 +1,9 @@ # coding: utf-8 import pytest +<<<<<<< HEAD from django.conf import settings +======= +>>>>>>> Changed DataViewSet response and added pagination from rest_framework import status from kpi.models.asset import Asset From 61e2425d1bd164ca1cf0c880a0d8f1e9f1045bbd Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Wed, 4 Sep 2019 21:20:10 -0400 Subject: [PATCH 145/499] =?UTF-8?q?Document=20that=20`current=5Fsubmission?= =?UTF-8?q?s=5Fcount`=20is=20not=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saved in `Asset.deployment` anywhere --- kpi/deployment_backends/kobocat_backend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index 954cbee6aa..c24277a34d 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -745,6 +745,7 @@ def __get_submissions_in_xml(self, **params): if len(instance_ids) > 0 or use_mongo: queryset = queryset.filter(id__in=instance_ids) + # Python-only attribute used by `kpi.views.v2.data.DataViewSet.list()` self.current_submissions_count = queryset.count() # Force Sort by id (see fixme above) From afd057e782be10b2b0f6a8c90acd5b18980fc4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 5 Sep 2019 11:41:04 -0400 Subject: [PATCH 146/499] Applied changes requested in PR#2331 --- kpi/deployment_backends/kobocat_backend.py | 39 +++++++----- kpi/paginators.py | 70 ++-------------------- kpi/views/v2/data.py | 3 +- 3 files changed, 29 insertions(+), 83 deletions(-) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index c24277a34d..2486846e2e 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -565,21 +565,28 @@ def get_submission_validation_status_url(self, submission_pk): def get_submission(self, pk, format_type=INSTANCE_FORMAT_TYPE_JSON, **kwargs): """ - Returns only one occurrence. + Returns submission if `pk` exists otherwise `None` - :param pk: int. `Instance.id` - :param format_type: str. INSTANCE_FORMAT_TYPE_JSON|INSTANCE_FORMAT_TYPE_XML - :param kwargs: dict. Filter params - :return: mixed. JSON or XML + + Args: + pk (int). Primary key. Must be a positive integer + format_type (str): INSTANCE_FORMAT_TYPE_JSON|INSTANCE_FORMAT_TYPE_XML + kwargs (dict): Filters to pass to MongoDB. See + https://docs.mongodb.com/manual/reference/operator/query/ + + Returns: + (dict|str|`None`): Depending of `format_type`, it can return: + - Mongo JSON representation as a dict + - Instance's XML as string + - `None` if doesn't exist """ - if pk: - submissions = list(self.get_submissions(format_type, [int(pk)], **kwargs)) - if len(submissions) > 0: - return submissions[0] - return None - else: - raise ValueError(_('Primary key must be provided')) + submissions = list(self.get_submissions(format_type, [int(pk)], **kwargs)) + try: + return submissions[0] + except IndexError: + pass + return None def get_submissions(self, format_type=INSTANCE_FORMAT_TYPE_JSON, instances_ids=[], **kwargs): """ @@ -615,9 +622,6 @@ def get_submissions(self, format_type=INSTANCE_FORMAT_TYPE_JSON, instances_ids=[ ) return submissions - def get_submissions_count(self, **kwargs): - pass - def get_validation_status(self, submission_pk, params, user): url = self.get_submission_validation_status_url(submission_pk) kc_request = requests.Request(method='GET', url=url, data=params) @@ -717,6 +721,11 @@ def __get_submissions_in_xml(self, **params): 'sort': _('This param is not supported in `XML` format') }) + # FIXME. Use Mongo to sort data and ask PostgreSQL to follow the order. + # See. https://stackoverflow.com/a/867578 + if 'sort' in kwargs: + raise ValueError(_('`sort` param is not supported with XML format')) + # Because `kwargs`' values are for `Mongo`'s query engine # We still use MongoHelper to validate params. params = self.validate_submission_list_params(**kwargs) diff --git a/kpi/paginators.py b/kpi/paginators.py index e786a41a7b..1561ae1fc0 100644 --- a/kpi/paginators.py +++ b/kpi/paginators.py @@ -6,77 +6,15 @@ PageNumberPagination, ) from rest_framework.reverse import reverse_lazy -from rest_framework.utils.urls import remove_query_param, replace_query_param -class DataPagination(PageNumberPagination): +class DataPagination(LimitOffsetPagination): """ Pagination class for submissions. - Because DataViewSet doesn't provide a real Django ORM QuerySet and pagination - is based on `start` and `limit` querystring parameters, some methods of - PageNumberPagination had to be rewritten to support that case. """ - page_size_query_param = 'limit' - start_query_param = 'start' - max_page_size = 500 - - def get_next_link(self): - if not self.page.has_next(): - return None - url = self.request.build_absolute_uri() - page_number = self.page.next_page_number() - return replace_query_param(url, self.start_query_param, - (page_number - 1) * self._page_size) - - def get_previous_link(self): - if not self.page.has_previous(): - return None - url = self.request.build_absolute_uri() - page_number = self.page.previous_page_number() - if page_number == 1: - return remove_query_param(url, self.start_query_param) - return replace_query_param(url, self.start_query_param, - (page_number - 1) * self._page_size) - - def get_start_value(self, request): - try: - return _positive_int( - request.query_params[self.start_query_param], - strict=True - ) - except (KeyError, ValueError): - pass - - return 0 - - def paginate_queryset(self, data, request, view=None): - """ - Paginate a queryset if required, either returning a - page object, or `None` if pagination is not configured for this view. - """ - page_size = self.get_page_size(request) - if not page_size: - return None - - paginator = self.django_paginator_class(data, page_size) - start = self.get_start_value(request) - page_number = int(start / page_size) + 1 - self._page_size = page_size - - try: - self.page = paginator.page(page_number) - except InvalidPage as exc: - msg = self.invalid_page_message.format( - page_number=page_number, message=str(exc) - ) - raise NotFound(msg) - - if paginator.num_pages > 1 and self.template is not None: - # The browsable API should display pagination controls. - self.display_page_controls = True - - self.request = request - return list(self.page) + default_limit = settings.SUBMISSION_LIST_LIMIT + offset_query_param = 'start' + max_limit = settings.SUBMISSION_LIST_LIMIT class DataPagination(LimitOffsetPagination): diff --git a/kpi/views/v2/data.py b/kpi/views/v2/data.py index 6ce3f26d61..7738706b21 100644 --- a/kpi/views/v2/data.py +++ b/kpi/views/v2/data.py @@ -215,7 +215,7 @@ def _get_deployment(self): Returns the deployment for the asset specified by the request """ if not self.asset.has_deployment: - raise serializers.ValidationError( + raise ValidationError( _('The specified asset has not been deployed')) return self.asset.deployment @@ -271,7 +271,6 @@ def list(self, request, *args, **kwargs): page = self.paginate_queryset(dummy_submissions_list) if page is not None: return self.get_paginated_response(submissions) - return Response(list(submissions)) def retrieve(self, request, pk, *args, **kwargs): From 2e62bd600259cafa5dd2351d97f3711ecaac4dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 5 Sep 2019 11:49:08 -0400 Subject: [PATCH 147/499] Removed useless carriage return --- kpi/deployment_backends/kobocat_backend.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index 2486846e2e..057bb00d2f 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -567,7 +567,6 @@ def get_submission(self, pk, format_type=INSTANCE_FORMAT_TYPE_JSON, **kwargs): """ Returns submission if `pk` exists otherwise `None` - Args: pk (int). Primary key. Must be a positive integer format_type (str): INSTANCE_FORMAT_TYPE_JSON|INSTANCE_FORMAT_TYPE_XML From a35c8e61818be445ea3a925cc7834dc65e289062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 5 Sep 2019 19:59:06 -0400 Subject: [PATCH 148/499] Applied second requested changes for PR #2331 --- kpi/deployment_backends/kobocat_backend.py | 4 +++- kpi/paginators.py | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index 057bb00d2f..8c25ef2f4c 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -723,7 +723,9 @@ def __get_submissions_in_xml(self, **params): # FIXME. Use Mongo to sort data and ask PostgreSQL to follow the order. # See. https://stackoverflow.com/a/867578 if 'sort' in kwargs: - raise ValueError(_('`sort` param is not supported with XML format')) + raise serializers.ValidationError({ + 'sort': _('This param is not supported in `XML` format') + }) # Because `kwargs`' values are for `Mongo`'s query engine # We still use MongoHelper to validate params. diff --git a/kpi/paginators.py b/kpi/paginators.py index 1561ae1fc0..e92a0be18f 100644 --- a/kpi/paginators.py +++ b/kpi/paginators.py @@ -28,9 +28,6 @@ class DataPagination(LimitOffsetPagination): class Paginated(LimitOffsetPagination): - """ Adds 'root' to the wrapping response object. """ - root = SerializerMethodField('get_parent_url', read_only=True) - def get_parent_url(self, obj): return reverse_lazy('api-root', request=self.context.get('request')) From e82b2d65c70cbcd68d7af1ce870334cbf99a8937 Mon Sep 17 00:00:00 2001 From: Raphaelle Date: Wed, 28 Aug 2019 14:21:56 -0400 Subject: [PATCH 149/499] Added assetsUid variable to permissions call --- jsapp/js/dataInterface.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 4058dde245..fdbe49251f 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -221,7 +221,7 @@ export var dataInterface; getPermissionsConfig() { return $ajax({ - url: `${ROOT_URL}/api/v2/permissions/`, + url: `${ROOT_URL}/api/v2/${assetUid}/permissions/`, method: 'GET' }); }, From 4c374c7eb17ae2a8e9aed4ed27721e69af83337c Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 5 Sep 2019 14:55:32 +0200 Subject: [PATCH 150/499] remove wrong url parameter --- jsapp/js/dataInterface.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index fdbe49251f..4058dde245 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -221,7 +221,7 @@ export var dataInterface; getPermissionsConfig() { return $ajax({ - url: `${ROOT_URL}/api/v2/${assetUid}/permissions/`, + url: `${ROOT_URL}/api/v2/permissions/`, method: 'GET' }); }, From 7c64a01b8e09f638b5d4b561530a3a23ad8bb280 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 5 Sep 2019 20:13:13 +0200 Subject: [PATCH 151/499] =?UTF-8?q?define=20get=E2=80=A6=20and=20assign?= =?UTF-8?q?=E2=80=A6=20collection=20permissions=20actions=20and=20dataInte?= =?UTF-8?q?rface=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jsapp/js/dataInterface.es6 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 4058dde245..97a32c8a74 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -241,6 +241,13 @@ export var dataInterface; }); }, + getCollectionPermissions(uid) { + return $ajax({ + url: `${ROOT_URL}/api/v2/collections/${uid}/permissions/`, + method: 'GET' + }); + }, + bulkSetAssetPermissions(assetUid, perms) { return $ajax({ url: `${ROOT_URL}/api/v2/assets/${assetUid}/permission-assignments/bulk/`, @@ -272,6 +279,16 @@ export var dataInterface; }); }, + assignCollectionPermission(uid, perm) { + return $ajax({ + url: `${ROOT_URL}/api/v2/collections/${uid}/permissions/`, + method: 'POST', + data: JSON.stringify(perm), + dataType: 'json', + contentType: 'application/json' + }); + }, + removeAssetPermission(perm) { return $ajax({ url: perm, From 125e2032910a585c16b3c2829a8b1108112f47f2 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 5 Sep 2019 22:18:22 +0200 Subject: [PATCH 152/499] update calls, some fixes left to be made --- .../js/components/permissions/sharingForm.es6 | 12 +++++++++++ .../permissions/userCollectionPermsEditor.es6 | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/jsapp/js/components/permissions/sharingForm.es6 b/jsapp/js/components/permissions/sharingForm.es6 index a7226038e6..29de862225 100644 --- a/jsapp/js/components/permissions/sharingForm.es6 +++ b/jsapp/js/components/permissions/sharingForm.es6 @@ -81,6 +81,18 @@ class SharingForm extends React.Component { }); } + onGetCollectionPermissionsCompleted(response) { + const parsedPerms = permParser.parseBackendData(response.results, this.state.asset.owner); + let nonOwnerPerms = permParser.parseUserWithPermsList(parsedPerms).filter((perm) => { + return perm.user !== buildUserUrl(this.state.asset.owner); + }); + + this.setState({ + permissions: parsedPerms, + nonOwnerPerms: nonOwnerPerms + }); + } + onAssetChange (data) { const uid = this.props.uid || this.currentAssetID; const asset = data[uid]; diff --git a/jsapp/js/components/permissions/userCollectionPermsEditor.es6 b/jsapp/js/components/permissions/userCollectionPermsEditor.es6 index 009a099248..683e33656b 100644 --- a/jsapp/js/components/permissions/userCollectionPermsEditor.es6 +++ b/jsapp/js/components/permissions/userCollectionPermsEditor.es6 @@ -6,20 +6,31 @@ import autoBind from 'react-autobind'; import Reflux from 'reflux'; import Checkbox from 'js/components/checkbox'; import TextBox from 'js/components/textBox'; +<<<<<<< HEAD import {stores} from 'js/stores'; import {actions} from 'js/actions'; import {bem} from 'js/bem'; +======= +import stores from 'js/stores'; +import actions from 'js/actions'; +import bem from 'js/bem'; +import classNames from 'classnames'; +>>>>>>> update calls, some fixes left to be made import permConfig from './permConfig'; import { t, notify, buildUserUrl } from 'js/utils'; +<<<<<<< HEAD import { ANON_USERNAME, PERMISSIONS_CODENAMES, COLLECTION_PERMISSIONS } from 'js/constants'; +======= +import {PERMISSIONS_CODENAMES} from 'js/constants'; +>>>>>>> update calls, some fixes left to be made /** * Form for adding/changing user permissions for collections. @@ -78,6 +89,7 @@ class UserCollectionPermissionsEditor extends React.Component { } componentDidMount() { +<<<<<<< HEAD this.listenTo(actions.permissions.assignCollectionPermission.completed, this.onChangeCollectionPermissionCompleted); this.listenTo(actions.permissions.assignCollectionPermission.failed, this.onChangeCollectionPermissionFailed); this.listenTo(actions.permissions.removeCollectionPermission.completed, this.onChangeCollectionPermissionCompleted); @@ -86,6 +98,14 @@ class UserCollectionPermissionsEditor extends React.Component { } onChangeCollectionPermissionCompleted() { +======= + this.listenTo(actions.permissions.assignCollectionPermission.completed, this.onAssignCollectionPermissionCompleted); + this.listenTo(actions.permissions.assignCollectionPermission.failed, this.onAssignCollectionPermissionFailed); + this.listenTo(stores.userExists, this.onUserExistsStoreChange); + } + + onAssignCollectionPermissionCompleted() { +>>>>>>> update calls, some fixes left to be made this.setState({isSubmitPending: false}); if (typeof this.props.onSubmitEnd === 'function') { this.props.onSubmitEnd(true); From dfdbf80ec84ace81b0a5ca592a2fb4776f29efa0 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 6 Sep 2019 11:26:30 +0200 Subject: [PATCH 153/499] unify permission removing --- .../permissions/userCollectionPermsEditor.es6 | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/jsapp/js/components/permissions/userCollectionPermsEditor.es6 b/jsapp/js/components/permissions/userCollectionPermsEditor.es6 index 683e33656b..ec50a0f0a2 100644 --- a/jsapp/js/components/permissions/userCollectionPermsEditor.es6 +++ b/jsapp/js/components/permissions/userCollectionPermsEditor.es6 @@ -6,31 +6,20 @@ import autoBind from 'react-autobind'; import Reflux from 'reflux'; import Checkbox from 'js/components/checkbox'; import TextBox from 'js/components/textBox'; -<<<<<<< HEAD import {stores} from 'js/stores'; import {actions} from 'js/actions'; import {bem} from 'js/bem'; -======= -import stores from 'js/stores'; -import actions from 'js/actions'; -import bem from 'js/bem'; -import classNames from 'classnames'; ->>>>>>> update calls, some fixes left to be made import permConfig from './permConfig'; import { t, notify, buildUserUrl } from 'js/utils'; -<<<<<<< HEAD import { ANON_USERNAME, PERMISSIONS_CODENAMES, COLLECTION_PERMISSIONS } from 'js/constants'; -======= -import {PERMISSIONS_CODENAMES} from 'js/constants'; ->>>>>>> update calls, some fixes left to be made /** * Form for adding/changing user permissions for collections. @@ -89,7 +78,7 @@ class UserCollectionPermissionsEditor extends React.Component { } componentDidMount() { -<<<<<<< HEAD + this.listenTo(actions.permissions.assignCollectionPermission.completed, this.onChangeCollectionPermissionCompleted); this.listenTo(actions.permissions.assignCollectionPermission.failed, this.onChangeCollectionPermissionFailed); this.listenTo(actions.permissions.removeCollectionPermission.completed, this.onChangeCollectionPermissionCompleted); @@ -98,14 +87,11 @@ class UserCollectionPermissionsEditor extends React.Component { } onChangeCollectionPermissionCompleted() { -======= - this.listenTo(actions.permissions.assignCollectionPermission.completed, this.onAssignCollectionPermissionCompleted); - this.listenTo(actions.permissions.assignCollectionPermission.failed, this.onAssignCollectionPermissionFailed); + this.listenTo(stores.userExists, this.onUserExistsStoreChange); } - onAssignCollectionPermissionCompleted() { ->>>>>>> update calls, some fixes left to be made + onChangeCollectionPermissionCompleted() { this.setState({isSubmitPending: false}); if (typeof this.props.onSubmitEnd === 'function') { this.props.onSubmitEnd(true); From 2c1b38a1f2f56a5ee3a01a8e5a7ca73f4a45f8ff Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 10 Sep 2019 18:59:22 +0200 Subject: [PATCH 154/499] undo temporary fix, use final one and move both methods next to each other --- jsapp/js/dataInterface.es6 | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 97a32c8a74..153614d200 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -89,16 +89,6 @@ export var dataInterface; return $.getJSON(`${ROOT_URL}/api/v2/collections/?all_public=true`); }, createAssetSnapshot (data) { - // Temporary fix - // ToDo when PR#2378 is merged, remove logic and - // use `${ROOT_URL}/api/v2/asset_snapshots/` directly - let v2Prefix = '/api/v2', - url = `${ROOT_URL}/asset_snapshots/`; - - if ('asset' in data && data['asset'].indexOf(`${ROOT_URL}${v2Prefix}`) === 0) { - url = `${ROOT_URL}${v2Prefix}/asset_snapshots/` - } - return $ajax({ url: `${ROOT_URL}/api/v2/asset_snapshots/`, method: 'POST', From 35d577c70b47dc40a26ee634a71ed6099b8b2b51 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Thu, 12 Sep 2019 15:17:26 -0400 Subject: [PATCH 155/499] WIP for Leszek --- kpi/serializers/v2/asset.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index d788ea69b6..e44c743e5d 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -319,6 +319,16 @@ def get_permissions(self, obj): many=True, read_only=True, context=context).data + def get_assignable_permissions(self, asset): + return [ + { + 'url': reverse('permission-detail', + kwargs={'codename': codename}, + request=self.context.get('request')), + 'label': asset.get_label_for_permission(codename), + } + for codename in asset.assignable_permissions] + def get_permissions(self, obj): context = self.context request = self.context.get('request') From bcc923ab8373b30b4aa36c57172b8b3d6dedfdac Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 13 Sep 2019 10:29:29 +0200 Subject: [PATCH 156/499] display assignablePerms labels in a list of user perms --- jsapp/js/components/permissions/userPermissionRow.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/jsapp/js/components/permissions/userPermissionRow.es6 b/jsapp/js/components/permissions/userPermissionRow.es6 index 5529c14737..4d3381f45a 100644 --- a/jsapp/js/components/permissions/userPermissionRow.es6 +++ b/jsapp/js/components/permissions/userPermissionRow.es6 @@ -1,3 +1,4 @@ +import _ from 'underscore'; import React from 'react'; import reactMixin from 'react-mixin'; import autoBind from 'react-autobind'; From 375f418a536f85de2bb710d58a41a52a5be9ef58 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 13 Sep 2019 11:53:20 +0200 Subject: [PATCH 157/499] fix collections perm editor --- jsapp/js/components/permissions/userPermissionRow.es6 | 11 +++++++++-- jsapp/js/constants.es6 | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionRow.es6 b/jsapp/js/components/permissions/userPermissionRow.es6 index 4d3381f45a..5d5cf4c6bd 100644 --- a/jsapp/js/components/permissions/userPermissionRow.es6 +++ b/jsapp/js/components/permissions/userPermissionRow.es6 @@ -88,9 +88,16 @@ class UserPermissionRow extends React.Component { } let permName = '???'; - if (this.props.assignablePerms.has(perm.permission)) { - permName = this.props.assignablePerms.get(perm.permission); + // TODO after collection is meged with asset simplify this + if (this.props.kind === ASSET_KINDS.get('asset')) { + if (this.props.assignablePerms.has(perm.permission)) { + permName = this.props.assignablePerms.get(perm.permission); + } } + if (this.props.kind === ASSET_KINDS.get('collection')) { + permName = COLLECTION_PERMISSIONS[permConfig.getPermission(perm.permission).codename]; + } + // ENDTODO // Hopefully this is friendly to translators of RTL languages let permNameTemplate; diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index aee34c0bd8..82d4725022 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -149,6 +149,7 @@ export default { ROOT_URL: ROOT_URL, ANON_USERNAME: ANON_USERNAME, PERMISSIONS_CODENAMES: PERMISSIONS_CODENAMES, + COLLECTION_PERMISSIONS: COLLECTION_PERMISSIONS, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, update_states: update_states, VALIDATION_STATUSES: VALIDATION_STATUSES, From 52bddf72a57fd9bd993697f5b4c2003d15cb3e83 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Fri, 13 Sep 2019 11:56:43 +0200 Subject: [PATCH 158/499] add better todo comments for collection merge --- jsapp/js/components/permissions/userPermissionRow.es6 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jsapp/js/components/permissions/userPermissionRow.es6 b/jsapp/js/components/permissions/userPermissionRow.es6 index 5d5cf4c6bd..8a4d1c547c 100644 --- a/jsapp/js/components/permissions/userPermissionRow.es6 +++ b/jsapp/js/components/permissions/userPermissionRow.es6 @@ -88,7 +88,7 @@ class UserPermissionRow extends React.Component { } let permName = '???'; - // TODO after collection is meged with asset simplify this + // TODO simplify this code when https://github.com/kobotoolbox/kpi/issues/2332 is done if (this.props.kind === ASSET_KINDS.get('asset')) { if (this.props.assignablePerms.has(perm.permission)) { permName = this.props.assignablePerms.get(perm.permission); @@ -97,7 +97,6 @@ class UserPermissionRow extends React.Component { if (this.props.kind === ASSET_KINDS.get('collection')) { permName = COLLECTION_PERMISSIONS[permConfig.getPermission(perm.permission).codename]; } - // ENDTODO // Hopefully this is friendly to translators of RTL languages let permNameTemplate; From cd19c30cb3cdd5d9a344a3947e1507238806784a Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Thu, 12 Sep 2019 15:17:26 -0400 Subject: [PATCH 159/499] Add `assignable_permissions` to v2 asset detail Towards #2316 --- kpi/serializers/v2/asset.py | 10 ++++++++++ kpi/tests/api/v2/test_api_assets.py | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index e44c743e5d..af7dfc7730 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -329,6 +329,16 @@ def get_assignable_permissions(self, asset): } for codename in asset.assignable_permissions] + def get_assignable_permissions(self, asset): + return [ + { + 'url': reverse('permission-detail', + kwargs={'codename': codename}, + request=self.context.get('request')), + 'label': asset.get_label_for_permission(codename), + } + for codename in asset.ASSIGNABLE_PERMISSIONS_BY_TYPE[asset.asset_type]] + def get_permissions(self, obj): context = self.context request = self.context.get('request') diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index f5bd55b1b9..18839da023 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -222,7 +222,9 @@ def test_restricted_access_to_version(self): class AssetsDetailApiTests(BaseAssetTestCase): fixtures = ['test_data'] - URL_NAMESPACE = ROUTER_URL_NAMESPACE + def __init__(self, *args, **kwargs): + self.URL_NAMESPACE = ROUTER_URL_NAMESPACE + return super(AssetsDetailApiTests, self).__init__(*args, **kwargs) def setUp(self): self.client.login(username='someuser', password='someuser') From 904b4369a4393f317feda6511197848a320bfa03 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Thu, 12 Sep 2019 16:57:31 -0400 Subject: [PATCH 160/499] Warn people off of embedded asset `permissions` See #2408 --- kpi/views/v2/asset.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/kpi/views/v2/asset.py b/kpi/views/v2/asset.py index 59ba4327a5..b3516f635f 100644 --- a/kpi/views/v2/asset.py +++ b/kpi/views/v2/asset.py @@ -44,6 +44,12 @@ class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): TODO Complete documentation + WARNING + Do not use the `permissions` array returned by this endpoint, as it will be + changed or removed in an upcoming release. Instead, use + /api/v2/assets/{uid}/permissions/ to retrieve the list + of permission assignments for a particular asset. + ## List of asset endpoints Lists the asset endpoints accessible to requesting user, for anonymous access From cdba7a633491ab6036dc9d227e55b979573b47e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 13 Sep 2019 15:18:49 -0400 Subject: [PATCH 161/499] Renamed nested 'permissions' endpoints to 'permission-assignments' --- .../permissions/permissionsMocks.es6 | 4 - jsapp/js/dataInterface.es6 | 6 +- kpi/models/asset.py | 1 - kpi/serializers/v2/asset.py | 8 +- kpi/serializers/v2/asset_permission.py | 228 ---------------- kpi/serializers/v2/collection_permission.py | 95 ------- kpi/tests/api/v2/test_api_asset_permission.py | 256 ------------------ .../test_api_asset_permission_assignment.py | 8 + .../api/v2/test_api_collection_permission.py | 250 ----------------- kpi/views/v2/asset.py | 2 +- kpi/views/v2/asset_permission.py | 242 ----------------- kpi/views/v2/collection_permission.py | 228 ---------------- 12 files changed, 16 insertions(+), 1312 deletions(-) delete mode 100644 kpi/serializers/v2/asset_permission.py delete mode 100644 kpi/serializers/v2/collection_permission.py delete mode 100644 kpi/tests/api/v2/test_api_asset_permission.py delete mode 100644 kpi/tests/api/v2/test_api_collection_permission.py delete mode 100644 kpi/views/v2/asset_permission.py delete mode 100644 kpi/views/v2/collection_permission.py diff --git a/jsapp/js/components/permissions/permissionsMocks.es6 b/jsapp/js/components/permissions/permissionsMocks.es6 index 0b56865a96..72b74efb30 100644 --- a/jsapp/js/components/permissions/permissionsMocks.es6 +++ b/jsapp/js/components/permissions/permissionsMocks.es6 @@ -192,11 +192,7 @@ const assetWithMultipleUsers = { 'permission': '/api/v2/permissions/view_submissions/' }, { -<<<<<<< HEAD 'url': '/api/v2/assets/arMB2dNgwewktv954wmo9e/permission-assignments/pETvxGayAJwvPaCnt5biVD/', -======= - 'url': '/api/v2/assets/arMB2dNgwewktv954wmo9e/permissions/pETvxGayAJwvPaCnt5biVD/', ->>>>>>> Fix a few typos causing front-end tests to fail 'user': '/api/v2/users/olivier/', 'permission': '/api/v2/permissions/view_asset/' }, diff --git a/jsapp/js/dataInterface.es6 b/jsapp/js/dataInterface.es6 index 153614d200..f5808fbd8f 100644 --- a/jsapp/js/dataInterface.es6 +++ b/jsapp/js/dataInterface.es6 @@ -233,7 +233,7 @@ export var dataInterface; getCollectionPermissions(uid) { return $ajax({ - url: `${ROOT_URL}/api/v2/collections/${uid}/permissions/`, + url: `${ROOT_URL}/api/v2/collections/${uid}/permission-assignments/`, method: 'GET' }); }, @@ -261,7 +261,7 @@ export var dataInterface; assignAssetPermission(assetUid, perm) { return $ajax({ - url: `${ROOT_URL}/api/v2/assets/${assetUid}/permissions/`, + url: `${ROOT_URL}/api/v2/assets/${assetUid}/permission-assignments/`, method: 'POST', data: JSON.stringify(perm), dataType: 'json', @@ -271,7 +271,7 @@ export var dataInterface; assignCollectionPermission(uid, perm) { return $ajax({ - url: `${ROOT_URL}/api/v2/collections/${uid}/permissions/`, + url: `${ROOT_URL}/api/v2/collections/${uid}/permission-assignments/`, method: 'POST', data: JSON.stringify(perm), dataType: 'json', diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 9bc0d23892..d0bc45f6fd 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -767,7 +767,6 @@ def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): return perms.get(perm) return None - def get_label_for_permission(self, permission_or_codename): try: codename = permission_or_codename.codename diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index af7dfc7730..a9ce6eb9f2 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -345,14 +345,14 @@ def get_permissions(self, obj): queryset = ObjectPermissionHelper.get_assignments_queryset(obj, request.user) # Need to pass `asset` and `asset_uid` to context of - # AssetPermissionSerializer serializer to avoid extra queries to DB + # AssetPermissionAssignmentSerializer serializer to avoid extra queries to DB # within the serializer to retrieve the asset object. context.update({'asset': obj}) context.update({'asset_uid': obj.uid}) - return AssetPermissionSerializer(queryset.all(), - many=True, read_only=True, - context=context).data + return AssetPermissionAssignmentSerializer(queryset.all(), + many=True, read_only=True, + context=context).data def _content(self, obj): return json.dumps(obj.content) diff --git a/kpi/serializers/v2/asset_permission.py b/kpi/serializers/v2/asset_permission.py deleted file mode 100644 index 50f9bc918e..0000000000 --- a/kpi/serializers/v2/asset_permission.py +++ /dev/null @@ -1,228 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from collections import defaultdict - -from django.contrib.auth.models import Permission, User -from django.core.urlresolvers import resolve, Resolver404 -from django.utils.translation import ugettext as _ -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.constants import PREFIX_PARTIAL_PERMS, SUFFIX_SUBMISSIONS_PERMS -from kpi.fields.relative_prefix_hyperlinked_related import \ - RelativePrefixHyperlinkedRelatedField -from kpi.models.asset import Asset -from kpi.models.object_permission import ObjectPermission -from kpi.utils.urls import absolute_resolve - - -class AssetPermissionSerializer(serializers.ModelSerializer): - - url = serializers.SerializerMethodField() - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - style={'base_template': 'input.html'} # Render as a simple text box - ) - permission = RelativePrefixHyperlinkedRelatedField( - view_name='permission-detail', - lookup_field='codename', - queryset=Permission.objects.all(), - style={'base_template': 'input.html'} # Render as a simple text box - ) - partial_permissions = serializers.SerializerMethodField() - - class Meta: - model = ObjectPermission - fields = ( - 'url', - 'user', - 'permission', - 'partial_permissions', - 'label', - ) - - read_only_fields = ('uid', 'label') - - def create(self, validated_data): - user = validated_data['user'] - asset = validated_data['asset'] - if asset.owner_id == user.id: - raise serializers.ValidationError({ - 'user': "Owner's permissions cannot be assigned explicitly"}) - permission = validated_data['permission'] - partial_permissions = validated_data.get('partial_permissions', None) - return asset.assign_perm(user, permission.codename, - partial_perms=partial_permissions) - - def get_partial_permissions(self, object_permission): - codename = object_permission.permission.codename - if codename.startswith(PREFIX_PARTIAL_PERMS): - view = self.context.get('view') - # if view doesn't have an `asset` property, - # fallback to context. (e.g. AssetViewSet) - asset = getattr(view, 'asset', self.context.get('asset')) - partial_perms = asset.get_partial_perms( - object_permission.user_id, with_filters=True) - - hyperlinked_partial_perms = [] - for perm_codename, filters in partial_perms.items(): - url = self.__get_permission_hyperlink(perm_codename) - hyperlinked_partial_perms.append({ - 'url': url, - 'filters': filters - }) - return hyperlinked_partial_perms - return None - - def get_url(self, object_permission): - asset_uid = self.context.get('asset_uid') - return reverse('asset-permission-detail', - args=(asset_uid, object_permission.uid), - request=self.context.get('request', None)) - - def validate(self, attrs): - # Because `partial_permissions` is a `SerializerMethodField`, - # it's read-only, so it's not validated nor added to `validated_data`. - # We need to do it manually - self.validate_partial_permissions(attrs) - return attrs - - def validate_partial_permissions(self, attrs): - """ - Validates permissions and filters sent with partial permissions. - - If data is valid, `partial_permissions` attribute is added to `attrs`. - Useful to permission assignment in `create()`. - - :param attrs: dict of `{'user': '', - 'permission': }` - :return: dict, the `attrs` parameter updated (if necessary) with - validated `partial_permissions` dicts. - """ - permission = attrs['permission'] - if not permission.codename.startswith(PREFIX_PARTIAL_PERMS): - # No additional validation needed - return attrs - - def _invalid_partial_permissions(message): - raise serializers.ValidationError( - {'partial_permissions': message} - ) - - request = self.context['request'] - partial_permissions = None - - if isinstance(request.data, dict): # for a single assignment - partial_permissions = request.data.get('partial_permissions') - elif self.context.get('partial_permissions'): # injected during bulk assignment - partial_permissions = self.context.get('partial_permissions') - - if not partial_permissions: - _invalid_partial_permissions( - _("This field is required for the '{}' permission").format( - permission.codename - ) - ) - - partial_permissions_attr = defaultdict(list) - - for partial_permission, filters_ in \ - self.__get_partial_permissions_generator(partial_permissions): - try: - resolver_match = absolute_resolve( - partial_permission.get('url') - ) - except (TypeError, Resolver404): - _invalid_partial_permissions(_('Invalid `url`')) - - try: - codename = resolver_match.kwargs['codename'] - except KeyError: - _invalid_partial_permissions(_('Invalid `url`')) - - # Permission must valid and must be assignable. - if not self._validate_permission(codename, - SUFFIX_SUBMISSIONS_PERMS): - _invalid_partial_permissions(_('Invalid `url`')) - - # No need to validate Mongo syntax, query will fail - # if syntax is not correct. - if not isinstance(filters_, dict): - _invalid_partial_permissions(_('Invalid `filters`')) - - # Validation passed! - partial_permissions_attr[codename].append(filters_) - - # Everything went well. Add it to `attrs` - attrs.update({'partial_permissions': partial_permissions_attr}) - - return attrs - - def validate_permission(self, permission): - """ - Checks if permission can be assigned on asset. - """ - if not self._validate_permission(permission.codename): - raise serializers.ValidationError( - '{} cannot be assigned explicitly to Asset objects.'.format( - permission.codename)) - return permission - - def to_representation(self, instance): - """ - Doesn't display 'partial_permissions' attribute if it's `None`. - """ - repr_ = super(AssetPermissionSerializer, self).to_representation(instance) - for k, v in repr_.items(): - if k == 'partial_permissions' and v is None: - del repr_[k] - - return repr_ - - def _validate_permission(self, codename, suffix=None): - """ - Validates if `codename` can be assigned on `Asset`s. - Search can be restricted to assignable codenames which end with `prefix` - - :param codename: str. See `Asset.ASSIGNABLE_PERMISSIONS - :param suffix: str. - :return: bool. - """ - return (codename in Asset.get_assignable_permissions(with_partial=True) - and (suffix is None or codename.endswith(suffix))) - - def __get_partial_permissions_generator(self, partial_permissions): - """ - Creates a generator to iterate over partial_permissions list. - Useful to validate each item and stop iterating as soon as errors - are detected - - :param partial_permissions: list - :return: generator - """ - for partial_permission in partial_permissions: - for filters_ in partial_permission.get('filters'): - yield partial_permission, filters_ - - def __get_permission_hyperlink(self, codename): - """ - Builds permission hyperlink representation. - :param codename: str - :return: str. url - """ - return reverse('permission-detail', - args=(codename,), - request=self.context.get('request', None)) - - -class AssetBulkInsertPermissionSerializer(AssetPermissionSerializer): - - class Meta: - model = ObjectPermission - fields = ( - 'user', - 'permission', - ) diff --git a/kpi/serializers/v2/collection_permission.py b/kpi/serializers/v2/collection_permission.py deleted file mode 100644 index 26715db227..0000000000 --- a/kpi/serializers/v2/collection_permission.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -from django.contrib.auth.models import Permission, User -from rest_framework import serializers -from rest_framework.reverse import reverse - -from kpi.fields.relative_prefix_hyperlinked_related import \ - RelativePrefixHyperlinkedRelatedField -from kpi.models.collection import Collection -from kpi.models.object_permission import ObjectPermission - - -class CollectionPermissionSerializer(serializers.ModelSerializer): - - url = serializers.SerializerMethodField() - user = RelativePrefixHyperlinkedRelatedField( - view_name='user-detail', - lookup_field='username', - queryset=User.objects.all(), - style={'base_template': 'input.html'} # Render as a simple text box - ) - permission = RelativePrefixHyperlinkedRelatedField( - view_name='permission-detail', - lookup_field='codename', - queryset=Permission.objects.all(), - style={'base_template': 'input.html'} # Render as a simple text box - ) - - class Meta: - model = ObjectPermission - fields = ( - 'url', - 'user', - 'permission' - ) - - read_only_fields = ('uid', ) - - def create(self, validated_data): - user = validated_data['user'] - collection = validated_data['collection'] - if collection.owner_id == user.id: - raise serializers.ValidationError({ - 'user': "Owner's permissions cannot be assigned explicitly"}) - permission = validated_data['permission'] - return collection.assign_perm(user, permission.codename) - - def get_url(self, object_permission): - collection_uid = self.context.get('collection_uid') - return reverse('collection-permission-detail', - args=(collection_uid, object_permission.uid), - request=self.context.get('request', None)) - - def validate_permission(self, permission): - """ - Checks if permission can be assigned on asset. - """ - if not self._validate_permission(permission.codename): - raise serializers.ValidationError( - '{} cannot be assigned explicitly to Asset objects.'.format( - permission.codename)) - return permission - - def _validate_permission(self, codename, suffix=None): - """ - Validates if `codename` can be assigned on `Collection`s. - Search can be restricted to assignable codenames which end with `prefix` - - :param codename: str. See `Collection.ASSIGNABLE_PERMISSIONS - :param suffix: str. - :return: bool. - """ - return (codename in Collection.get_assignable_permissions(with_partial=True) - and (suffix is None or codename.endswith(suffix))) - - def __get_permission_hyperlink(self, codename): - """ - Builds permission hyperlink representation. - :param codename: str - :return: str. url - """ - return reverse('permission-detail', - args=(codename,), - request=self.context.get('request', None)) - - -class CollectionBulkInsertPermissionSerializer(CollectionPermissionSerializer): - - class Meta: - model = ObjectPermission - fields = ( - 'user', - 'permission', - ) diff --git a/kpi/tests/api/v2/test_api_asset_permission.py b/kpi/tests/api/v2/test_api_asset_permission.py deleted file mode 100644 index 74617541f5..0000000000 --- a/kpi/tests/api/v2/test_api_asset_permission.py +++ /dev/null @@ -1,256 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -from rest_framework import status - -from kpi.constants import PERM_VIEW_ASSET, PERM_CHANGE_ASSET -from kpi.models import Asset -from kpi.models.object_permission import get_anonymous_user -from kpi.tests.kpi_test_case import KpiTestCase -from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE - - -class BaseApiAssetPermissionTestCase(KpiTestCase): - - fixtures = ["test_data"] - - URL_NAMESPACE = ROUTER_URL_NAMESPACE - - def setUp(self): - self.admin = User.objects.get(username='admin') - self.someuser = User.objects.get(username='someuser') - self.anotheruser = User.objects.get(username='anotheruser') - - self.client.login(username='admin', password='pass') - self.asset = self.create_asset('An asset to be shared') - - self.admin_detail_url = reverse( - self._get_endpoint('user-detail'), - kwargs={'username': self.admin.username}) - - self.someuser_detail_url = reverse( - self._get_endpoint('user-detail'), - kwargs={'username': self.someuser.username}) - - self.anotheruser_detail_url = reverse( - self._get_endpoint('user-detail'), - kwargs={'username': self.anotheruser.username}) - - self.view_asset_permission_detail_url = reverse( - self._get_endpoint('permission-detail'), - kwargs={'codename': PERM_VIEW_ASSET}) - - self.change_asset_permission_detail_url = reverse( - self._get_endpoint('permission-detail'), - kwargs={'codename': PERM_CHANGE_ASSET}) - - self.asset_permissions_list_url = reverse( - self._get_endpoint('asset-permission-list'), - kwargs={'parent_lookup_asset': self.asset.uid} - ) - - def _logged_user_gives_permission(self, username, permission): - """ - Uses the API to grant `permission` to `username` - """ - data = { - 'user': getattr(self, '{}_detail_url'.format(username)), - 'permission': getattr(self, '{}_permission_detail_url'.format(permission)) - } - response = self.client.post(self.asset_permissions_list_url, - data, format='json') - return response - - -class ApiAssetPermissionTestCase(BaseApiAssetPermissionTestCase): - - def test_owner_can_give_permissions(self): - # Current user is `self.admin` - response = self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_viewers_can_not_give_permissions(self): - self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) - self.client.login(username='someuser', password='someuser') - # Current user is now: `self.someuser` - response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_ASSET) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_editors_can_give_permissions(self): - self._logged_user_gives_permission('someuser', PERM_CHANGE_ASSET) - self.client.login(username='someuser', password='someuser') - # Current user is now: `self.someuser` - response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_ASSET) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_anonymous_can_not_give_permissions(self): - self.client.logout() - response = self._logged_user_gives_permission('someuser', PERM_VIEW_ASSET) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -class ApiAssetPermissionListTestCase(BaseApiAssetPermissionTestCase): - """ - TODO Refactor tests - Redundant codes - """ - fixtures = ["test_data"] - - URL_NAMESPACE = ROUTER_URL_NAMESPACE - - def setUp(self): - super(ApiAssetPermissionListTestCase, self).setUp() - - self.asset.assign_perm(self.someuser, PERM_CHANGE_ASSET) - self.asset.assign_perm(self.anotheruser, PERM_VIEW_ASSET) - - def test_viewers_see_only_their_own_assignments_and_owner_s(self): - - # Checks if can see all permissions - self.client.login(username='anotheruser', password='anotheruser') - permission_list_response = self.client.get(self.asset_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - admin_perms = self.asset.get_perms(self.admin) - anotheruser_perms = self.asset.get_perms(self.anotheruser) - results = permission_list_response.data.get('results') - - # `anotheruser` can only see the owner's permissions `self.admin` and - # `anotheruser`'s permissions. Should not see `someuser`s ones. - expected_perms = [] - for admin_perm in admin_perms: - if admin_perm in Asset.get_assignable_permissions(): - expected_perms.append((self.admin.username, admin_perm)) - for anotheruser_perm in anotheruser_perms: - if anotheruser_perm in Asset.get_assignable_permissions(): - expected_perms.append((self.anotheruser.username, anotheruser_perm)) - - expected_perms = sorted(expected_perms, key=lambda element: (element[0], - element[1])) - obj_perms = [] - for assignment in results: - object_permission = self.url_to_obj(assignment.get('url')) - obj_perms.append((object_permission.user.username, - object_permission.permission.codename)) - - obj_perms = sorted(obj_perms, key=lambda element: (element[0], - element[1])) - - self.assertEqual(expected_perms, obj_perms) - - def test_editors_see_all_assignments(self): - - self.client.login(username='someuser', password='someuser') - permission_list_response = self.client.get(self.asset_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - admin_perms = self.asset.get_perms(self.admin) - someuser_perms = self.asset.get_perms(self.someuser) - anotheruser_perms = self.asset.get_perms(self.anotheruser) - results = permission_list_response.data.get('results') - - # As an editor of the asset. `someuser` should see all. - expected_perms = [] - for admin_perm in admin_perms: - if admin_perm in Asset.get_assignable_permissions(): - expected_perms.append((self.admin.username, admin_perm)) - for someuser_perm in someuser_perms: - if someuser_perm in Asset.get_assignable_permissions(): - expected_perms.append((self.someuser.username, someuser_perm)) - for anotheruser_perm in anotheruser_perms: - if anotheruser_perm in Asset.get_assignable_permissions(): - expected_perms.append((self.anotheruser.username, anotheruser_perm)) - - expected_perms = sorted(expected_perms, key=lambda element: (element[0], - element[1])) - obj_perms = [] - for assignment in results: - object_permission = self.url_to_obj(assignment.get('url')) - obj_perms.append((object_permission.user.username, - object_permission.permission.codename)) - - obj_perms = sorted(obj_perms, key=lambda element: (element[0], - element[1])) - - self.assertEqual(expected_perms, obj_perms) - - def test_anonymous_get_only_owner_s_assignments(self): - - self.client.logout() - self.asset.assign_perm(get_anonymous_user(), PERM_VIEW_ASSET) - permission_list_response = self.client.get(self.asset_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - admin_perms = self.asset.get_perms(self.admin) - results = permission_list_response.data.get('results') - - # Get admin permissions. - expected_perms = [] - for admin_perm in admin_perms: - if admin_perm in Asset.get_assignable_permissions(): - expected_perms.append((self.admin.username, admin_perm)) - - expected_perms = sorted(expected_perms, key=lambda element: (element[0], - element[1])) - obj_perms = [] - for assignment in results: - object_permission = self.url_to_obj(assignment.get('url')) - obj_perms.append((object_permission.user.username, - object_permission.permission.codename)) - - obj_perms = sorted(obj_perms, key=lambda element: (element[0], - element[1])) - self.assertEqual(expected_perms, obj_perms) - - -class ApiBulkAssetPermissionTestCase(BaseApiAssetPermissionTestCase): - - def _logged_user_gives_permissions(self, assignments): - """ - Uses the API to grant `permission` to `username` - """ - url = '{}bulk/'.format(self.asset_permissions_list_url) - - def get_data_template(username_, permission_): - return { - 'user': getattr(self, '{}_detail_url'.format(username_)), - 'permission': getattr(self, '{}_permission_detail_url'.format( - permission_)) - } - - data = [] - for username, permission in assignments: - data.append(get_data_template(username, permission)) - response = self.client.post(url, data, format='json') - return response - - def test_cannot_assign_permissions_to_owner(self): - self._logged_user_gives_permission('someuser', PERM_CHANGE_ASSET) - self.client.login(username='someuser', password='someuser') - response = self._logged_user_gives_permissions([ - ('admin', PERM_VIEW_ASSET), - ('admin', PERM_CHANGE_ASSET) - ]) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_bulk_assign_permissions(self): - # TODO Improve this test - permission_list_response = self.client.get(self.asset_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - total = permission_list_response.data.get('count') - # Add number of permissions added with 'view_asset' - total += len(Asset.get_implied_perms(PERM_VIEW_ASSET)) + 1 - # Add number of permissions added with 'change_asset' - total += len(Asset.get_implied_perms(PERM_CHANGE_ASSET)) + 1 - - response = self._logged_user_gives_permissions([ - ('someuser', PERM_VIEW_ASSET), - ('someuser', PERM_VIEW_ASSET), # Add a duplicate which should not count - ('anotheruser', PERM_CHANGE_ASSET) - ]) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('count'), total) - diff --git a/kpi/tests/api/v2/test_api_asset_permission_assignment.py b/kpi/tests/api/v2/test_api_asset_permission_assignment.py index 737a484f24..f450ab51a2 100644 --- a/kpi/tests/api/v2/test_api_asset_permission_assignment.py +++ b/kpi/tests/api/v2/test_api_asset_permission_assignment.py @@ -253,7 +253,11 @@ def test_bulk_assign_permissions(self): permission_list_response = self.client.get(self.asset_permissions_list_url, format='json') self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) +<<<<<<< HEAD total = len(permission_list_response.data) +======= + total = permission_list_response.data.get('count') +>>>>>>> Renamed nested 'permissions' endpoints to 'permission-assignments' # Add number of permissions added with 'view_asset' total += len(Asset.get_implied_perms(PERM_VIEW_ASSET)) + 1 # Add number of permissions added with 'change_asset' @@ -266,5 +270,9 @@ def test_bulk_assign_permissions(self): ]) self.assertEqual(response.status_code, status.HTTP_200_OK) +<<<<<<< HEAD self.assertEqual(len(response.data), total) +======= + self.assertEqual(response.data.get('count'), total) +>>>>>>> Renamed nested 'permissions' endpoints to 'permission-assignments' diff --git a/kpi/tests/api/v2/test_api_collection_permission.py b/kpi/tests/api/v2/test_api_collection_permission.py deleted file mode 100644 index 74d0ea6675..0000000000 --- a/kpi/tests/api/v2/test_api_collection_permission.py +++ /dev/null @@ -1,250 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -from rest_framework import status - -from kpi.constants import PERM_VIEW_COLLECTION, PERM_CHANGE_COLLECTION -from kpi.models import Collection -from kpi.models.object_permission import get_anonymous_user -from kpi.tests.kpi_test_case import KpiTestCase -from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE - - -class BaseApiCollectionPermissionTestCase(KpiTestCase): - - fixtures = ["test_data"] - - URL_NAMESPACE = ROUTER_URL_NAMESPACE - - def setUp(self): - self.admin = User.objects.get(username='admin') - self.someuser = User.objects.get(username='someuser') - self.anotheruser = User.objects.get(username='anotheruser') - - self.client.login(username='admin', password='pass') - self.collection = self.create_collection('A collection to be shared') - - self.admin_detail_url = reverse( - self._get_endpoint('user-detail'), - kwargs={'username': self.admin.username}) - - self.someuser_detail_url = reverse( - self._get_endpoint('user-detail'), - kwargs={'username': self.someuser.username}) - - self.anotheruser_detail_url = reverse( - self._get_endpoint('user-detail'), - kwargs={'username': self.anotheruser.username}) - - self.view_collection_permission_detail_url = reverse( - self._get_endpoint('permission-detail'), - kwargs={'codename': PERM_VIEW_COLLECTION}) - - self.change_collection_permission_detail_url = reverse( - self._get_endpoint('permission-detail'), - kwargs={'codename': PERM_CHANGE_COLLECTION}) - - self.collection_permissions_list_url = reverse( - self._get_endpoint('collection-permission-list'), - kwargs={'parent_lookup_collection': self.collection.uid} - ) - - def _logged_user_gives_permission(self, username, permission): - data = { - 'user': getattr(self, '{}_detail_url'.format(username)), - 'permission': getattr(self, '{}_permission_detail_url'.format(permission)) - } - response = self.client.post(self.collection_permissions_list_url, - data, format='json') - return response - - -class ApiCollectionPermissionTestCase(BaseApiCollectionPermissionTestCase): - - def test_owner_can_give_permissions(self): - # Current user is `self.admin` - response = self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_viewers_can_not_give_permissions(self): - self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) - self.client.login(username='someuser', password='someuser') - # Current user is now: `self.someuser` - response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_COLLECTION) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - - def test_editors_can_give_permissions(self): - self._logged_user_gives_permission('someuser', PERM_CHANGE_COLLECTION) - self.client.login(username='someuser', password='someuser') - # Current user is now: `self.someuser` - response = self._logged_user_gives_permission('anotheruser', PERM_VIEW_COLLECTION) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - - def test_anonymous_can_not_give_permissions(self): - self.client.logout() - response = self._logged_user_gives_permission('someuser', PERM_VIEW_COLLECTION) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - -class ApiCollectionPermissionListTestCase(BaseApiCollectionPermissionTestCase): - fixtures = ["test_data"] - - URL_NAMESPACE = ROUTER_URL_NAMESPACE - - def setUp(self): - super(ApiCollectionPermissionListTestCase, self).setUp() - - self.collection.assign_perm(self.someuser, PERM_CHANGE_COLLECTION) - self.collection.assign_perm(self.anotheruser, PERM_VIEW_COLLECTION) - - def test_viewers_see_only_their_own_assignments_and_owner_s(self): - - # Checks if can see all permissions - self.client.login(username='anotheruser', password='anotheruser') - permission_list_response = self.client.get(self.collection_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - admin_perms = self.collection.get_perms(self.admin) - anotheruser_perms = self.collection.get_perms(self.anotheruser) - results = permission_list_response.data.get('results') - - # `anotheruser` can only see the owner's permissions `self.admin` and - # `anotheruser`'s permissions. Should not see `someuser`s ones. - expected_perms = [] - for admin_perm in admin_perms: - if admin_perm in Collection.get_assignable_permissions(): - expected_perms.append((self.admin.username, admin_perm)) - for anotheruser_perm in anotheruser_perms: - if anotheruser_perm in Collection.get_assignable_permissions(): - expected_perms.append((self.anotheruser.username, anotheruser_perm)) - - expected_perms = sorted(expected_perms, key=lambda element: (element[0], - element[1])) - obj_perms = [] - for assignment in results: - object_permission = self.url_to_obj(assignment.get('url')) - obj_perms.append((object_permission.user.username, - object_permission.permission.codename)) - - obj_perms = sorted(obj_perms, key=lambda element: (element[0], - element[1])) - - self.assertEqual(expected_perms, obj_perms) - - def test_editors_see_all_assignments(self): - - self.client.login(username='someuser', password='someuser') - permission_list_response = self.client.get(self.collection_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - admin_perms = self.collection.get_perms(self.admin) - someuser_perms = self.collection.get_perms(self.someuser) - anotheruser_perms = self.collection.get_perms(self.anotheruser) - results = permission_list_response.data.get('results') - - # As an editor of the collection. `someuser` should see all. - expected_perms = [] - for admin_perm in admin_perms: - if admin_perm in Collection.get_assignable_permissions(): - expected_perms.append((self.admin.username, admin_perm)) - for someuser_perm in someuser_perms: - if someuser_perm in Collection.get_assignable_permissions(): - expected_perms.append((self.someuser.username, someuser_perm)) - for anotheruser_perm in anotheruser_perms: - if anotheruser_perm in Collection.get_assignable_permissions(): - expected_perms.append((self.anotheruser.username, anotheruser_perm)) - - expected_perms = sorted(expected_perms, key=lambda element: (element[0], - element[1])) - obj_perms = [] - for assignment in results: - object_permission = self.url_to_obj(assignment.get('url')) - obj_perms.append((object_permission.user.username, - object_permission.permission.codename)) - - obj_perms = sorted(obj_perms, key=lambda element: (element[0], - element[1])) - - self.assertEqual(expected_perms, obj_perms) - - def test_anonymous_get_only_owner_s_assignments(self): - - self.client.logout() - self.collection.assign_perm(get_anonymous_user(), PERM_VIEW_COLLECTION) - permission_list_response = self.client.get(self.collection_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - admin_perms = self.collection.get_perms(self.admin) - results = permission_list_response.data.get('results') - - # As an editor of the collection. `someuser` should see all. - expected_perms = [] - for admin_perm in admin_perms: - if admin_perm in Collection.get_assignable_permissions(): - expected_perms.append((self.admin.username, admin_perm)) - - expected_perms = sorted(expected_perms, key=lambda element: (element[0], - element[1])) - obj_perms = [] - for assignment in results: - object_permission = self.url_to_obj(assignment.get('url')) - obj_perms.append((object_permission.user.username, - object_permission.permission.codename)) - - obj_perms = sorted(obj_perms, key=lambda element: (element[0], - element[1])) - self.assertEqual(expected_perms, obj_perms) - - -class ApiBulkCollectionPermissionTestCase(BaseApiCollectionPermissionTestCase): - - def _logged_user_gives_permissions(self, assignments): - """ - Uses the API to grant `permission` to `username` - """ - url = '{}bulk/'.format(self.collection_permissions_list_url) - - def get_data_template(username_, permission_): - return { - 'user': getattr(self, '{}_detail_url'.format(username_)), - 'permission': getattr(self, '{}_permission_detail_url'.format( - permission_)) - } - - data = [] - for username, permission in assignments: - data.append(get_data_template(username, permission)) - - response = self.client.post(url, data, format='json') - return response - - def test_cannot_assign_permissions_to_owner(self): - self._logged_user_gives_permission('someuser', PERM_CHANGE_COLLECTION) - self.client.login(username='someuser', password='someuser') - response = self._logged_user_gives_permissions([ - ('admin', PERM_VIEW_COLLECTION), - ('admin', PERM_CHANGE_COLLECTION) - ]) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_bulk_assign_permissions(self): - # TODO Improve this test - permission_list_response = self.client.get(self.collection_permissions_list_url, - format='json') - self.assertEqual(permission_list_response.status_code, status.HTTP_200_OK) - total = permission_list_response.data.get('count') - # Add number of permissions added with 'view_collection' - total += len(Collection.get_implied_perms(PERM_VIEW_COLLECTION)) + 1 - # Add number of permissions added with 'change_collection' - total += len(Collection.get_implied_perms(PERM_CHANGE_COLLECTION)) + 1 - - response = self._logged_user_gives_permissions([ - ('someuser', PERM_VIEW_COLLECTION), - ('someuser', PERM_VIEW_COLLECTION), # Add a duplicate which should not count - ('anotheruser', PERM_CHANGE_COLLECTION) - ]) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get('count'), total) diff --git a/kpi/views/v2/asset.py b/kpi/views/v2/asset.py index b3516f635f..b9f563c073 100644 --- a/kpi/views/v2/asset.py +++ b/kpi/views/v2/asset.py @@ -47,7 +47,7 @@ class AssetViewSet(NestedViewSetMixin, viewsets.ModelViewSet): WARNING Do not use the `permissions` array returned by this endpoint, as it will be changed or removed in an upcoming release. Instead, use - /api/v2/assets/{uid}/permissions/ to retrieve the list + /api/v2/assets/{uid}/permission-assignments/ to retrieve the list of permission assignments for a particular asset. ## List of asset endpoints diff --git a/kpi/views/v2/asset_permission.py b/kpi/views/v2/asset_permission.py deleted file mode 100644 index a6bc3bf1ba..0000000000 --- a/kpi/views/v2/asset_permission.py +++ /dev/null @@ -1,242 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -from django.db import transaction -from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext as _ -from rest_framework import exceptions, viewsets, status, renderers -from rest_framework.decorators import list_route -from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ - DestroyModelMixin, ListModelMixin -from rest_framework.response import Response -from rest_framework_extensions.mixins import NestedViewSetMixin - -from kpi.constants import CLONE_ARG_NAME, PERM_VIEW_ASSET, PERM_SHARE_ASSET -from kpi.models.asset import Asset -from kpi.models.object_permission import ObjectPermission -from kpi.permissions import AssetNestedObjectPermission -from kpi.serializers.v2.asset_permission import AssetPermissionSerializer, \ - AssetBulkInsertPermissionSerializer -from kpi.utils.object_permission_helper import ObjectPermissionHelper -from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin - - -class AssetPermissionViewSet(AssetNestedObjectViewsetMixin, NestedViewSetMixin, - CreateModelMixin, RetrieveModelMixin, - DestroyModelMixin, ListModelMixin, - viewsets.GenericViewSet): - """ - ## Permissions of an asset - - This endpoint shows assignments on an asset. An assignment implies: - - - a `Permission` object - - a `User` object - - **Roles' permissions:** - - - Owner sees all permissions - - Editors see all permissions - - Viewers see owner's permissions and their permissions - - Anonymous users see only owner's permissions - - - `uid` - is the unique identifier of a specific asset - - **Retrieve assignments** -
-    GET /api/v2/assets/{uid}/permissions/
-    
- - > Example - > - > curl -X GET https://[kpi]/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/ - - - **Assign a permission** -
-    POST /api/v2/assets/{uid}/permissions/
-    
- - > Example - > - > curl -X POST https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/ \\ - > -H 'Content-Type: application/json' \\ - > -d '' # Payload is sent as the string - - - > _Payload to assign a permission_ - > - > { - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{codename}/", - > } - - > _Payload to assign partial permissions_ - > - > { - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{partial_permission_codename}/", - > "partial_permissions": [ - > { - > "url": "https://[kpi]/api/v2/permissions/{codename}/", - > "filters": [ - > {"_submitted_by": {"$in": ["{username}", "{username}"]}} - > ] - > }, - > ] - > } - - N.B.: - - - Only submissions support partial (`view`) permissions so far. - - Filters use Mongo Query Engine to narrow down results. - - Implied permissions will be also assigned. (e.g. `change_asset` will add `view_asset` too) - - - - **Remove a permission** - -
-    DELETE /api/v2/assets/{uid}/permissions/{permission_uid}/
-    
- - > Example - > - > curl -X DELETE https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/pG6AeSjCwNtpWazQAX76Ap/ - - - **Assign all permissions at once** - - All permissions will erased (except the owner's) before new assignments -
-    POST /api/v2/assets/{uid}/permissions/bulk/
-    
- - > Example - > - > curl -X POST https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/bulk/ - - > _Payload to assign all permissions at once_ - > - > [{ - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{codename}/", - > }, - > { - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{codename}/", - > },...] - - - **Clone permissions from another asset** - - All permissions will erased (except the owner's) before new assignments -
-    PATCH /api/v2/assets/{uid}/permissions/clone/
-    
- - > Example - > - > curl -X PATCH https://[kpi]/api/v2/assets/aSAvYreNzVEkrWg5Gdcvg/permissions/clone/ - - > _Payload to clone permissions from another asset_ - > - > { - > "clone_from": "{source_asset_uid}" - > } - - ### CURRENT ENDPOINT - """ - - model = ObjectPermission - lookup_field = "uid" - serializer_class = AssetPermissionSerializer - permission_classes = (AssetNestedObjectPermission,) - - @list_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer], - url_path='bulk') - def bulk_assignments(self, request, *args, **kwargs): - """ - Assigns all permissions at once for the same asset. - - :param request: - :return: JSON - """ - - assignments = request.data - - # We don't want to lock tables, only queries to rollback in case - # one assignment fails. - with transaction.atomic(): - - # First delete all assignments before assigning new ones. - # If something fails later, this query should rollback - self.asset.permissions.exclude(user__username=self.asset.owner.username).delete() - - for assignment in assignments: - context_ = dict(self.get_serializer_context()) - if 'partial_permissions' in assignment: - context_.update({'partial_permissions': assignment['partial_permissions']}) - serializer = AssetBulkInsertPermissionSerializer( - data=assignment, - context=context_ - ) - serializer.is_valid(raise_exception=True) - serializer.save(asset=self.asset) - - # returns asset permissions. Users who can change permissions can - # see all permissions. - return self.list(request, *args, **kwargs) - - @list_route(methods=['PATCH'], renderer_classes=[renderers.JSONRenderer]) - def clone(self, request, *args, **kwargs): - - source_asset_uid = self.request.data[CLONE_ARG_NAME] - source_asset = get_object_or_404(Asset, uid=source_asset_uid) - user = request.user - - if user.has_perm(PERM_SHARE_ASSET, self.asset) and \ - user.has_perm(PERM_VIEW_ASSET, source_asset): - if not self.asset.copy_permissions_from(source_asset): - http_status = status.HTTP_400_BAD_REQUEST - response = {'detail': _("Source and destination objects don't " - "seem to have the same type")} - return Response(response, status=http_status) - else: - raise exceptions.PermissionDenied() - - # returns asset permissions. Users who can change permissions can - # see all permissions. - return self.list(request, *args, **kwargs) - - def destroy(self, request, *args, **kwargs): - object_permission = self.get_object() - user = object_permission.user - if user.pk == self.asset.owner_id: - return Response({ - 'detail': _("Owner's permissions cannot be deleted") - }, status=status.HTTP_409_CONFLICT) - - codename = object_permission.permission.codename - self.asset.remove_perm(user, codename) - return Response(status=status.HTTP_204_NO_CONTENT) - - def get_serializer_context(self): - """ - Extra context provided to the serializer class. - Inject asset_uid to avoid extra queries to DB inside the serializer. - """ - - context_ = super(AssetPermissionViewSet, self).get_serializer_context() - context_.update({ - 'asset_uid': self.asset.uid - }) - return context_ - - def get_queryset(self): - return ObjectPermissionHelper.get_assignments_queryset(self.asset, - self.request.user) - - def perform_create(self, serializer): - serializer.save(asset=self.asset) diff --git a/kpi/views/v2/collection_permission.py b/kpi/views/v2/collection_permission.py deleted file mode 100644 index 7d9aa2c5d3..0000000000 --- a/kpi/views/v2/collection_permission.py +++ /dev/null @@ -1,228 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, unicode_literals - -from django.db import transaction -from django.shortcuts import get_object_or_404 -from django.utils.translation import ugettext as _ -from rest_framework import exceptions, viewsets, status, renderers -from rest_framework.decorators import list_route -from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, \ - DestroyModelMixin, ListModelMixin -from rest_framework.response import Response -from rest_framework_extensions.mixins import NestedViewSetMixin - -from kpi.constants import CLONE_ARG_NAME, PERM_SHARE_COLLECTION, \ - PERM_VIEW_COLLECTION -from kpi.models.collection import Collection -from kpi.models.object_permission import ObjectPermission -from kpi.permissions import CollectionNestedObjectPermission -from kpi.serializers.v2.collection_permission import CollectionPermissionSerializer, \ - CollectionBulkInsertPermissionSerializer -from kpi.utils.object_permission_helper import ObjectPermissionHelper -from kpi.utils.viewset_mixins import CollectionNestedObjectViewsetMixin - - -class CollectionPermissionViewSet(CollectionNestedObjectViewsetMixin, NestedViewSetMixin, - CreateModelMixin, RetrieveModelMixin, - DestroyModelMixin, ListModelMixin, - viewsets.GenericViewSet): - - # TODO Refactor AssetPermissionViewSet & CollectionPermissionViewSet tox - # use same core. - - """ - ## Permissions of an collection - - This endpoint shows assignments on an collection. An assignment implies: - - - a `Permission` object - - a `User` object - - **Roles' permissions:** - - - Owner sees all permissions - - Editors see all permissions - - Viewers see owner's permissions and their permissions - - Anonymous users see only owner's permissions - - - `uid` - is the unique identifier of a specific collection - - **Retrieve assignments** -
-    GET /api/v2/collections/{uid}/permissions/
-    
- - > Example - > - > curl -X GET https://[kpi]/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/ - - - **Assign a permission** -
-    POST /api/v2/collections/{uid}/permissions/
-    
- - > Example - > - > curl -X POST https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/ \\ - > -H 'Content-Type: application/json' \\ - > -d '' # Payload is sent as the string - - - > _Payload to assign a permission_ - > - > { - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{codename}/", - > } - - N.B.: - - - Implied permissions will be also assigned. (e.g. `change_collection` will add `view_collection` too) - - - - **Remove a permission** - -
-    DELETE /api/v2/collections/{uid}/permissions/{permission_uid}/
-    
- - > Example - > - > curl -X DELETE https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/pG6AeSjCwNtpWazQAX76Ap/ - - - **Assign all permissions at once** - - All permissions will erased (except the owner's) before new assignments -
-    POST /api/v2/collections/{uid}/permissions/bulk/
-    
- - > Example - > - > curl -X POST https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/bulk/ - - > _Payload to assign all permissions at once_ - > - > [{ - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{codename}/", - > }, - > { - > "user": "https://[kpi]/api/v2/users/{username}/", - > "permission": "https://[kpi]/api/v2/permissions/{codename}/", - > },...] - - - **Clone permissions from another collection** - - All permissions will erased (except the owner's) before new assignments -
-    PATCH /api/v2/collections/{uid}/permissions/clone/
-    
- - > Example - > - > curl -X PATCH https://[kpi]/api/v2/collections/cSAvYreNzVEkrWg5Gdcvg/permissions/clone/ - - > _Payload to clone permissions from another collection_ - > - > { - > "clone_from": "{source_collection_uid}" - > } - - ### CURRENT ENDPOINT - """ - - model = ObjectPermission - lookup_field = "uid" - serializer_class = CollectionPermissionSerializer - permission_classes = (CollectionNestedObjectPermission,) - - @list_route(methods=['POST'], renderer_classes=[renderers.JSONRenderer], - url_path='bulk') - def bulk_assignments(self, request, *args, **kwargs): - """ - Assigns all permissions at once for the same collection. - - :param request: - :return: JSON - """ - - assignments = request.data - - # We don't want to lock tables, only queries to rollback in case - # one assignment fails. - with transaction.atomic(): - - # First delete all assignments before assigning new ones. - # If something fails later, this query should rollback - self.collection.permissions.exclude( - user__username=self.collection.owner.username).delete() - - for assignment in assignments: - context_ = dict(self.get_serializer_context()) - serializer = CollectionBulkInsertPermissionSerializer( - data=assignment, - context=context_ - ) - serializer.is_valid(raise_exception=True) - serializer.save(collection=self.collection) - - # returns collection permissions. Users who can change permissions can - # see all permissions. - return self.list(request, *args, **kwargs) - - @list_route(methods=['PATCH'], renderer_classes=[renderers.JSONRenderer]) - def clone(self, request, *args, **kwargs): - - source_collection_uid = self.request.data[CLONE_ARG_NAME] - source_collection = get_object_or_404(Collection, uid=source_collection_uid) - user = request.user - - if user.has_perm(PERM_SHARE_COLLECTION, self.collection) and \ - user.has_perm(PERM_VIEW_COLLECTION, source_collection): - if not self.collection.copy_permissions_from(source_collection): - http_status = status.HTTP_400_BAD_REQUEST - response = {'detail': _("Source and destination objects don't " - "seem to have the same type")} - return Response(response, status=http_status) - else: - raise exceptions.PermissionDenied() - - # returns collection permissions. Users who can change permissions can - # see all permissions. - return self.list(request, *args, **kwargs) - - def destroy(self, request, *args, **kwargs): - object_permission = self.get_object() - user = object_permission.user - if user.pk == self.collection.owner_id: - return Response({ - 'detail': _("Owner's permissions cannot be deleted") - }, status=status.HTTP_409_CONFLICT) - - codename = object_permission.permission.codename - self.collection.remove_perm(user, codename) - return Response(status=status.HTTP_204_NO_CONTENT) - - def get_serializer_context(self): - """ - Extra context provided to the serializer class. - Inject collection_uid to avoid extra queries to DB inside the serializer. - """ - context_ = super(CollectionPermissionViewSet, self).get_serializer_context() - context_.update({ - 'collection_uid': self.collection.uid - }) - return context_ - - def get_queryset(self): - return ObjectPermissionHelper.get_assignments_queryset(self.collection, - self.request.user) - - def perform_create(self, serializer): - serializer.save(collection=self.collection) From 2fe15bcfde4485e57d1d47772c24d16fc0fff9ad Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Fri, 13 Sep 2019 15:18:12 -0400 Subject: [PATCH 162/499] Apply changes from #2409 review --- kpi/tests/api/v2/test_api_assets.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index 18839da023..f5bd55b1b9 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -222,9 +222,7 @@ def test_restricted_access_to_version(self): class AssetsDetailApiTests(BaseAssetTestCase): fixtures = ['test_data'] - def __init__(self, *args, **kwargs): - self.URL_NAMESPACE = ROUTER_URL_NAMESPACE - return super(AssetsDetailApiTests, self).__init__(*args, **kwargs) + URL_NAMESPACE = ROUTER_URL_NAMESPACE def setUp(self): self.client.login(username='someuser', password='someuser') From 7f442eed5fd723f22930f5df5f64a91838eeeb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 30 Aug 2019 17:08:42 -0400 Subject: [PATCH 163/499] Filter data in reports/exports when user has 'partial_submission' permssion --- kobo/apps/reports/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kobo/apps/reports/views.py b/kobo/apps/reports/views.py index 0b8237047b..2a1109e320 100644 --- a/kobo/apps/reports/views.py +++ b/kobo/apps/reports/views.py @@ -2,6 +2,7 @@ from django.http import Http404 from rest_framework import viewsets, mixins +<<<<<<< HEAD from kpi.constants import ( ASSET_TYPE_SURVEY, PERM_PARTIAL_SUBMISSIONS, @@ -10,6 +11,10 @@ from kpi.models import Asset from kpi.models.object_permission import get_objects_for_user, get_anonymous_user from .serializers import ReportsListSerializer, ReportsDetailSerializer +======= +from kpi.models import Asset +from kpi.models.object_permission import get_objects_for_user, get_anonymous_user +>>>>>>> Filter data in reports/exports when user has 'partial_submission' permssion from kpi.constants import PERM_VIEW_SUBMISSIONS, PERM_PARTIAL_SUBMISSIONS From b7e0635c1ed1410334807df46c421d483b28f066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 30 Aug 2019 18:05:37 -0400 Subject: [PATCH 164/499] Added tests for exports and users with 'partial_submissions' permission --- kpi/tests/test_mock_data_exports.py | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/kpi/tests/test_mock_data_exports.py b/kpi/tests/test_mock_data_exports.py index 272700df57..ab482d8c74 100644 --- a/kpi/tests/test_mock_data_exports.py +++ b/kpi/tests/test_mock_data_exports.py @@ -303,6 +303,18 @@ def test_csv_export_english_labels(self): ] self.run_csv_export_test(expected_lines, export_options) + def test_csv_export_english_labels_partial_submissions(self): + export_options = { + 'lang': 'English', + } + expected_lines = [ + '"start";"end";"What kind of symmetry do you have?";"What kind of symmetry do you have?/Spherical";"What kind of symmetry do you have?/Radial";"What kind of symmetry do you have?/Bilateral";"How many segments does your body have?";"Do you have body fluids that occupy intracellular space?";"Do you descend from an ancestral unicellular organism?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', + '"";"";"#symmetry";"#symmetry";"#symmetry";"#symmetry";"#segments";"#fluids";"";"";"";"";"";""', + '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', + ] + self.run_csv_export_test(expected_lines, export_options, + user=self.anotheruser) + def test_csv_export_spanish_labels(self): export_options = { 'lang': 'Spanish', @@ -316,6 +328,18 @@ def test_csv_export_spanish_labels(self): ] self.run_csv_export_test(expected_lines, export_options) + def test_csv_export_spanish_labels_partial_submissions(self): + export_options = { + 'lang': 'Spanish', + } + expected_lines = [ + '"start";"end";"¿Qué tipo de simetría tiene?";"¿Qué tipo de simetría tiene?/Esférico";"¿Qué tipo de simetría tiene?/Radial";"¿Qué tipo de simetría tiene?/Bilateral";"¿Cuántos segmentos tiene tu cuerpo?";"¿Tienes fluidos corporales que ocupan espacio intracelular?";"¿Desciende de un organismo unicelular ancestral?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', + '"";"";"#symmetry";"#symmetry";"#symmetry";"#symmetry";"#segments";"#fluids";"";"";"";"";"";""', + '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Inseguro";"Sí";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', + ] + self.run_csv_export_test(expected_lines, export_options, + user=self.anotheruser) + def test_csv_export_english_labels_no_hxl(self): export_options = { 'lang': 'English', @@ -328,6 +352,18 @@ def test_csv_export_english_labels_no_hxl(self): '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"3"', ] self.run_csv_export_test(expected_lines, export_options) + + def test_csv_export_english_labels_no_hxl_partial_submissions(self): + export_options = { + 'lang': 'English', + 'tag_cols_for_header': [], + } + expected_lines = [ + '"start";"end";"What kind of symmetry do you have?";"What kind of symmetry do you have?/Spherical";"What kind of symmetry do you have?/Radial";"What kind of symmetry do you have?/Bilateral";"How many segments does your body have?";"Do you have body fluids that occupy intracellular space?";"Do you descend from an ancestral unicellular organism?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', + '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', + ] + self.run_csv_export_test(expected_lines, export_options, + user=self.anotheruser) def test_csv_export_english_labels_group_sep(self): # Check `group_sep` by looking at the `select_multiple` question @@ -344,6 +380,20 @@ def test_csv_export_english_labels_group_sep(self): ] self.run_csv_export_test(expected_lines, export_options) + def test_csv_export_english_labels_group_sep_partial_submissions(self): + # Check `group_sep` by looking at the `select_multiple` question + export_options = { + 'lang': 'English', + 'group_sep': '%', + } + expected_lines = [ + '"start";"end";"What kind of symmetry do you have?";"What kind of symmetry do you have?%Spherical";"What kind of symmetry do you have?%Radial";"What kind of symmetry do you have?%Bilateral";"How many segments does your body have?";"Do you have body fluids that occupy intracellular space?";"Do you descend from an ancestral unicellular organism?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', + '"";"";"#symmetry";"#symmetry";"#symmetry";"#symmetry";"#segments";"#fluids";"";"";"";"";"";""', + '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', + ] + self.run_csv_export_test(expected_lines, export_options, + user=self.anotheruser) + def test_csv_export_hierarchy_in_labels(self): export_options = {'hierarchy_in_labels': 'true'} expected_lines = [ From ceb376020f5bb8437b270f7f9b60d94025b06fcb Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 3 Sep 2019 13:41:37 -0400 Subject: [PATCH 165/499] Sync django_digest_partial_digest table between kpi and kc databases --- .../kc_access/shadow_models.py | 53 ++++++++++++++++++- kpi/signals.py | 35 +++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index eb666f0070..faba8b6695 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -480,7 +480,7 @@ def sync(cls, auth_user): class KobocatToken(ShadowModel): key = models.CharField(_("Key"), max_length=40, primary_key=True) - user = models.OneToOneField(getattr(settings, 'AUTH_USER_MODEL', 'auth.User'), + user = models.OneToOneField(KobocatUser, related_name='auth_token', on_delete=models.CASCADE, verbose_name=_("User")) created = models.DateTimeField(_("Created"), auto_now_add=True) @@ -495,11 +495,60 @@ def sync(cls, auth_token): # Thus, we can retrieve tokens from users' id. kc_auth_token = cls.objects.get(user_id=auth_token.user_id) except KobocatToken.DoesNotExist: - kc_auth_token = cls(pk=auth_token.pk, user=auth_token.user) + kc_auth_token = cls(pk=auth_token.pk, user_id=auth_token.user_id) kc_auth_token.save() +class KobocatDigestPartial(ShadowModel): + + user = models.ForeignKey(KobocatUser, on_delete=models.CASCADE) + login = models.CharField(max_length=128, db_index=True) + partial_digest = models.CharField(max_length=100) + confirmed = models.BooleanField(default=True) + + class Meta(ShadowModel.Meta): + db_table = "django_digest_partialdigest" + + @classmethod + def sync(cls, digest_partial, validate_user=True): + """` + Sync `django_digest_partialdigest` table between `kpi` and `kc`` + + A race condition occurs when users are created. + `DigestPartial` post-signal is (often) triggered before `User` + post-signal. Because of that, user doesn't exist in `kc` database + when `KobocatDigestPartial` is saved. + + `validate_user` is useful to verify whether foreign key exists to avoid + getting an `IntegrityError` on save. + + Args: + digest_partial (DigestPartial) + validate_user (bool) + """ + try: + if validate_user: + # Race condition. `User` post signal can be triggered after + # `DigestPartial` post signal. + KobocatUser.objects.get(pk=digest_partial.user_id) + + try: + kc_digest_partial = cls.objects.get(pk=digest_partial.pk) + assert kc_digest_partial.user_id == digest_partial.user_id + except KobocatDigestPartial.DoesNotExist: + kc_digest_partial = cls(pk=digest_partial.pk, + user_id=digest_partial.user_id) + + kc_digest_partial.login = digest_partial.login + kc_digest_partial.partial_digest = digest_partial.partial_digest + kc_digest_partial.confirmed = kc_digest_partial.confirmed + kc_digest_partial.save() + + except KobocatUser.DoesNotExist: + pass + + def safe_kc_read(func): def _wrapper(*args, **kwargs): try: diff --git a/kpi/signals.py b/kpi/signals.py index 26dcf359a0..73a7037d13 100644 --- a/kpi/signals.py +++ b/kpi/signals.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import User from django.db.models.signals import post_save, post_delete from django.dispatch import receiver +from django_digest.models import PartialDigest from rest_framework.authtoken.models import Token from taggit.models import Tag @@ -12,6 +13,7 @@ from kpi.deployment_backends.kc_access.shadow_models import ( KobocatToken, KobocatUser, + KobocatDigestPartial ) from kpi.deployment_backends.kc_access.utils import grant_kc_model_level_perms from kpi.models import Asset, Collection, ObjectPermission, TagUid @@ -54,6 +56,13 @@ def save_kobocat_user(sender, instance, created, raw, **kwargs): # assigning model-level permissions fails grant_kc_model_level_perms(instance) + # Force PartialDigest to be sync'ed on creation + partial_digests = PartialDigest.objects.filter(user_id=instance.pk) + for partial_digest in partial_digests: + # `KobocatUser` should exist at this point. + # We don't need to validate `KobocatUser`'s existence. + KobocatDigestPartial.sync(partial_digest, validate_user=False) + @receiver(post_save, sender=Token) def save_kobocat_token(sender, instance, **kwargs): @@ -70,7 +79,31 @@ def delete_kobocat_token(sender, instance, **kwargs): Delete corresponding record from KC AuthToken table """ if not settings.TESTING: - KobocatToken.objects.filter(pk=instance.pk).delete() + try: + KobocatToken.objects.get(pk=instance.pk).delete() + except KobocatToken.DoesNotExist: + pass + + +@receiver(post_save, sender=PartialDigest) +def save_kobocat_partial_digest(sender, instance, **kwargs): + """ + Sync PartialDigest table between KPI and KC + """ + if not settings.TESTING: + KobocatDigestPartial.sync(instance) + + +@receiver(post_delete, sender=PartialDigest) +def delete_kobocat_partial_digest(sender, instance, **kwargs): + """ + Delete corresponding record from KC PartialDigest table + """ + if not settings.TESTING: + try: + KobocatDigestPartial.objects.get(pk=instance.pk).delete() + except KobocatDigestPartial.DoesNotExist: + pass @receiver(post_save, sender=Tag) From 33de05ac73e6d61357aecdd15112de6db9ba2a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Tue, 3 Sep 2019 22:14:10 -0400 Subject: [PATCH 166/499] Return submission count according to user's permissions --- kpi/tests/api/v2/test_api_assets.py | 8 ++++- kpi/utils/mongo_helper.py | 48 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index f5bd55b1b9..47113ff9f6 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -15,7 +15,13 @@ PERM_PARTIAL_SUBMISSIONS, ) -from kpi.constants import PERM_CHANGE_ASSET, PERM_VIEW_ASSET +from kpi.constants import ( + PERM_CHANGE_ASSET, + PERM_VIEW_ASSET, + PERM_VIEW_SUBMISSIONS, + PERM_PARTIAL_SUBMISSIONS, +) + from kpi.models import Asset from kpi.models import AssetFile from kpi.models import AssetVersion diff --git a/kpi/utils/mongo_helper.py b/kpi/utils/mongo_helper.py index 356a76110e..f83b64de0f 100644 --- a/kpi/utils/mongo_helper.py +++ b/kpi/utils/mongo_helper.py @@ -329,6 +329,54 @@ def _get_cursor_and_count(cls, mongo_userform_id, hide_deleted=True, cursor = settings.MONGO_DB.instances.find(query, fields_to_select) return cursor, cursor.count() + @classmethod + def _get_cursor(cls, mongo_userform_id, hide_deleted=True, fields=None, + query=None, instances_ids=None, permission_filters=None): + # check if query contains an _id and if its a valid ObjectID + if '_uuid' in query: + if ObjectId.is_valid(query.get('_uuid')): + query['_uuid'] = ObjectId(query.get('_uuid')) + else: + raise ValidationError(_('Invalid _uuid specified')) + + if len(instances_ids) > 0: + query.update({ + '_id': {'$in': instances_ids} + }) + + query.update({cls.USERFORM_ID: mongo_userform_id}) + + # Narrow down query + if permission_filters is not None: + permission_filters_query = {'$or': []} + for permission_filter in permission_filters: + permission_filters_query['$or'].append(permission_filter) + + query = {'$and': [query, permission_filters_query]} + + if hide_deleted: + # display only active elements + deleted_at_query = { + '$or': [{'_deleted_at': {'$exists': False}}, + {'_deleted_at': None}]} + # join existing query with deleted_at_query on an $and + query = {'$and': [query, deleted_at_query]} + + query = cls.to_safe_dict(query, reading=True) + + if len(fields) > 0: + # Retrieve only specified fields from Mongo. Remove + # `cls.USERFORM_ID` from those fields in case users try to add it. + if cls.USERFORM_ID in fields: + fields.remove(cls.USERFORM_ID) + fields_to_select = dict( + [(cls.encode(field), 1) for field in fields]) + else: + # Retrieve all fields except `cls.USERFORM_ID` + fields_to_select = {cls.USERFORM_ID: 0} + + return settings.MONGO_DB.instances.find(query, fields_to_select) + @classmethod def _is_attribute_encoded(cls, key): """ From c1b8a7c13eea49e579da9b59a2b37cf376715c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 6 Sep 2019 11:28:21 -0400 Subject: [PATCH 167/499] Moved the filtering logic of partial permissions into `get_submissions()` --- kobo/apps/hook/tests/hook_test_case.py | 4 +++ kobo/apps/hook/tests/test_api_hook.py | 4 +++ kobo/apps/hook/tests/test_parser.py | 24 +++++++++++++ kobo/apps/hook/views/v2/hook_signal.py | 13 +++++++ kpi/tests/test_mock_data_exports.py | 50 -------------------------- 5 files changed, 45 insertions(+), 50 deletions(-) diff --git a/kobo/apps/hook/tests/hook_test_case.py b/kobo/apps/hook/tests/hook_test_case.py index 31adf8c405..4715cec660 100644 --- a/kobo/apps/hook/tests/hook_test_case.py +++ b/kobo/apps/hook/tests/hook_test_case.py @@ -100,7 +100,11 @@ def _send_and_fail(self): ServiceDefinition = self.hook.get_service_definition() submissions = self.asset.deployment.get_submissions(self.asset.owner.id) +<<<<<<< HEAD instance_id = submissions[0].get(self.asset.deployment.INSTANCE_ID_FIELDNAME) +======= + instance_id = submissions[0].get("id") +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` service_definition = ServiceDefinition(self.hook, instance_id) first_mock_response = {'error': 'not found'} diff --git a/kobo/apps/hook/tests/test_api_hook.py b/kobo/apps/hook/tests/test_api_hook.py index 264839ddc1..46fa45edce 100644 --- a/kobo/apps/hook/tests/test_api_hook.py +++ b/kobo/apps/hook/tests/test_api_hook.py @@ -64,8 +64,12 @@ def test_data_submission(self): hook_signal_url = reverse("hook-signal-list", kwargs={"parent_lookup_asset": self.asset.uid}) submissions = self.asset.deployment.get_submissions(self.asset.owner.id) +<<<<<<< HEAD data = {"instance_id": submissions[0].get( self.asset.deployment.INSTANCE_ID_FIELDNAME)} +======= + data = {"instance_id": submissions[0].get("id")} +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` response = self.client.post(hook_signal_url, data=data, format='json') self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) diff --git a/kobo/apps/hook/tests/test_parser.py b/kobo/apps/hook/tests/test_parser.py index bbe0cb670a..d4c259f9e0 100644 --- a/kobo/apps/hook/tests/test_parser.py +++ b/kobo/apps/hook/tests/test_parser.py @@ -16,7 +16,11 @@ def test_json_parser(self): ServiceDefinition = hook.get_service_definition() submissions = hook.asset.deployment.get_submissions(hook.asset.owner.id) +<<<<<<< HEAD uuid = submissions[0].get(hook.asset.deployment.INSTANCE_ID_FIELDNAME) +======= + uuid = submissions[0].get("id") +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` service_definition = ServiceDefinition(hook, uuid) expected_data = { 'group1/q3': u'¿Cómo está en el grupo uno la segunda vez?', @@ -35,7 +39,11 @@ def test_xml_parser(self): self.asset_xml.deploy(backend='mock', active=True) self.asset_xml.save() +<<<<<<< HEAD hook = self._create_hook(subset_fields=['_id', 'subgroup1', 'q3'], +======= + hook = self._create_hook(subset_fields=["id", "subgroup1", "q3"], +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` format_type=INSTANCE_FORMAT_TYPE_XML) ServiceDefinition = hook.get_service_definition() @@ -47,6 +55,7 @@ def test_xml_parser(self): service_definition = ServiceDefinition(hook, uuid) expected_etree = etree.fromstring( +<<<<<<< HEAD ('<{asset_uid}>' ' ' ' ¿Cómo está en el grupo uno la segunda vez?' @@ -60,6 +69,21 @@ def test_xml_parser(self): ' ' ' <_id>{id}' '').format( +======= + ("<{asset_uid}>" + " " + " ¿Cómo está en el grupo uno la segunda vez?" + " " + " " + " " + " ¿Cómo está en el subgrupo uno la primera vez?" + " ¿Cómo está en el subgrupo uno la segunda vez?" + " ¿Cómo está en el subgrupo uno la tercera vez?" + " " + " " + " {id}" + "").format( +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` asset_uid=self.asset_xml.uid, id=uuid) ) diff --git a/kobo/apps/hook/views/v2/hook_signal.py b/kobo/apps/hook/views/v2/hook_signal.py index 2d91cfcb6d..f87a62f0ec 100644 --- a/kobo/apps/hook/views/v2/hook_signal.py +++ b/kobo/apps/hook/views/v2/hook_signal.py @@ -47,6 +47,7 @@ def create(self, request, *args, **kwargs): :param request: :return: """ +<<<<<<< HEAD try: instance_id = positive_int( request.data.get('instance_id'), strict=True) @@ -55,6 +56,13 @@ def create(self, request, *args, **kwargs): {'instance_id': _('A positive integer is required.')}) # Check if instance really belongs to Asset. +======= + instance_id = request.data.get("instance_id") + if instance_id is None: + raise serializers.ValidationError( + {'instance_id': _('This field is required.')}) + +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` try: instance = self.asset.deployment.get_submission(instance_id, request.user.id) @@ -71,8 +79,13 @@ def create(self, request, *args, **kwargs): response_status_code = status.HTTP_202_ACCEPTED response = { "detail": _( +<<<<<<< HEAD "We got and saved your data, but may not have " "fully processed it. You should not try to resubmit.") +======= + "We got and saved your data, but may not have fully " + "processed it. You should not try to resubmit.") +>>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` } else: # call_services() refused to launch any task because this diff --git a/kpi/tests/test_mock_data_exports.py b/kpi/tests/test_mock_data_exports.py index ab482d8c74..cdf357c1db 100644 --- a/kpi/tests/test_mock_data_exports.py +++ b/kpi/tests/test_mock_data_exports.py @@ -303,18 +303,6 @@ def test_csv_export_english_labels(self): ] self.run_csv_export_test(expected_lines, export_options) - def test_csv_export_english_labels_partial_submissions(self): - export_options = { - 'lang': 'English', - } - expected_lines = [ - '"start";"end";"What kind of symmetry do you have?";"What kind of symmetry do you have?/Spherical";"What kind of symmetry do you have?/Radial";"What kind of symmetry do you have?/Bilateral";"How many segments does your body have?";"Do you have body fluids that occupy intracellular space?";"Do you descend from an ancestral unicellular organism?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', - '"";"";"#symmetry";"#symmetry";"#symmetry";"#symmetry";"#segments";"#fluids";"";"";"";"";"";""', - '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', - ] - self.run_csv_export_test(expected_lines, export_options, - user=self.anotheruser) - def test_csv_export_spanish_labels(self): export_options = { 'lang': 'Spanish', @@ -328,18 +316,6 @@ def test_csv_export_spanish_labels(self): ] self.run_csv_export_test(expected_lines, export_options) - def test_csv_export_spanish_labels_partial_submissions(self): - export_options = { - 'lang': 'Spanish', - } - expected_lines = [ - '"start";"end";"¿Qué tipo de simetría tiene?";"¿Qué tipo de simetría tiene?/Esférico";"¿Qué tipo de simetría tiene?/Radial";"¿Qué tipo de simetría tiene?/Bilateral";"¿Cuántos segmentos tiene tu cuerpo?";"¿Tienes fluidos corporales que ocupan espacio intracelular?";"¿Desciende de un organismo unicelular ancestral?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', - '"";"";"#symmetry";"#symmetry";"#symmetry";"#symmetry";"#segments";"#fluids";"";"";"";"";"";""', - '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Inseguro";"Sí";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', - ] - self.run_csv_export_test(expected_lines, export_options, - user=self.anotheruser) - def test_csv_export_english_labels_no_hxl(self): export_options = { 'lang': 'English', @@ -353,18 +329,6 @@ def test_csv_export_english_labels_no_hxl(self): ] self.run_csv_export_test(expected_lines, export_options) - def test_csv_export_english_labels_no_hxl_partial_submissions(self): - export_options = { - 'lang': 'English', - 'tag_cols_for_header': [], - } - expected_lines = [ - '"start";"end";"What kind of symmetry do you have?";"What kind of symmetry do you have?/Spherical";"What kind of symmetry do you have?/Radial";"What kind of symmetry do you have?/Bilateral";"How many segments does your body have?";"Do you have body fluids that occupy intracellular space?";"Do you descend from an ancestral unicellular organism?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', - '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', - ] - self.run_csv_export_test(expected_lines, export_options, - user=self.anotheruser) - def test_csv_export_english_labels_group_sep(self): # Check `group_sep` by looking at the `select_multiple` question export_options = { @@ -380,20 +344,6 @@ def test_csv_export_english_labels_group_sep(self): ] self.run_csv_export_test(expected_lines, export_options) - def test_csv_export_english_labels_group_sep_partial_submissions(self): - # Check `group_sep` by looking at the `select_multiple` question - export_options = { - 'lang': 'English', - 'group_sep': '%', - } - expected_lines = [ - '"start";"end";"What kind of symmetry do you have?";"What kind of symmetry do you have?%Spherical";"What kind of symmetry do you have?%Radial";"What kind of symmetry do you have?%Bilateral";"How many segments does your body have?";"Do you have body fluids that occupy intracellular space?";"Do you descend from an ancestral unicellular organism?";"_id";"_uuid";"_submission_time";"_validation_status";"_index"', - '"";"";"#symmetry";"#symmetry";"#symmetry";"#symmetry";"#segments";"#fluids";"";"";"";"";"";""', - '"2017-10-23T05:41:32.000-04:00";"2017-10-23T05:42:05.000-04:00";"Bilateral";"0";"0";"1";"2";"No / Unsure";"Yes";"63";"3f15cdfe-3eab-4678-8352-7806febf158d";"2017-10-23T09:42:11";"";"1"', - ] - self.run_csv_export_test(expected_lines, export_options, - user=self.anotheruser) - def test_csv_export_hierarchy_in_labels(self): export_options = {'hierarchy_in_labels': 'true'} expected_lines = [ From ccdae219487481b1d16d5bf0825defd2e8e1e7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 6 Sep 2019 14:31:41 -0400 Subject: [PATCH 168/499] Merge branch '2319-update-data-api-call' into 2385-partial-submission-permissions-fixes-tmp Moved filtering logic for `partial_view_submissions` to `validate_submission_list_params` Removed useless extra ajax call to get submissions count --- kobo/apps/hook/views/v2/hook_signal.py | 17 ++++++----------- kpi/tests/api/v1/test_api_submissions.py | 4 +--- kpi/utils/mongo_helper.py | 3 ++- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/kobo/apps/hook/views/v2/hook_signal.py b/kobo/apps/hook/views/v2/hook_signal.py index f87a62f0ec..289e6a6c53 100644 --- a/kobo/apps/hook/views/v2/hook_signal.py +++ b/kobo/apps/hook/views/v2/hook_signal.py @@ -47,7 +47,7 @@ def create(self, request, *args, **kwargs): :param request: :return: """ -<<<<<<< HEAD + try: instance_id = positive_int( request.data.get('instance_id'), strict=True) @@ -56,13 +56,12 @@ def create(self, request, *args, **kwargs): {'instance_id': _('A positive integer is required.')}) # Check if instance really belongs to Asset. -======= - instance_id = request.data.get("instance_id") - if instance_id is None: raise serializers.ValidationError( - {'instance_id': _('This field is required.')}) + {'instance_id': _('A positive integer is required.')}) + + + # Check if instance really belongs to Asset. ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` try: instance = self.asset.deployment.get_submission(instance_id, request.user.id) @@ -79,13 +78,9 @@ def create(self, request, *args, **kwargs): response_status_code = status.HTTP_202_ACCEPTED response = { "detail": _( -<<<<<<< HEAD + "We got and saved your data, but may not have " "fully processed it. You should not try to resubmit.") -======= - "We got and saved your data, but may not have fully " - "processed it. You should not try to resubmit.") ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` } else: # call_services() refused to launch any task because this diff --git a/kpi/tests/api/v1/test_api_submissions.py b/kpi/tests/api/v1/test_api_submissions.py index bd003a590b..151ec40734 100644 --- a/kpi/tests/api/v1/test_api_submissions.py +++ b/kpi/tests/api/v1/test_api_submissions.py @@ -1,9 +1,7 @@ # coding: utf-8 import pytest -<<<<<<< HEAD + from django.conf import settings -======= ->>>>>>> Changed DataViewSet response and added pagination from rest_framework import status from kpi.models.asset import Asset diff --git a/kpi/utils/mongo_helper.py b/kpi/utils/mongo_helper.py index f83b64de0f..93f7962e75 100644 --- a/kpi/utils/mongo_helper.py +++ b/kpi/utils/mongo_helper.py @@ -375,7 +375,8 @@ def _get_cursor(cls, mongo_userform_id, hide_deleted=True, fields=None, # Retrieve all fields except `cls.USERFORM_ID` fields_to_select = {cls.USERFORM_ID: 0} - return settings.MONGO_DB.instances.find(query, fields_to_select) + cursor = settings.MONGO_DB.instances.find(query, fields_to_select) + return cursor, cursor.count() @classmethod def _is_attribute_encoded(cls, key): From 2356cc7fde697081267b0d774aa50ea2930866f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 6 Sep 2019 15:43:25 -0400 Subject: [PATCH 169/499] Fixed bugs: - 'v1': SubmissionViewSet didn't use the correct signature of backend methods - 'unittests': Some tests didn't use to correction version of API - Removed forgotten '<<<<' from latest merge --- kpi/tests/api/v1/test_api_submissions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kpi/tests/api/v1/test_api_submissions.py b/kpi/tests/api/v1/test_api_submissions.py index 151ec40734..0148c250e6 100644 --- a/kpi/tests/api/v1/test_api_submissions.py +++ b/kpi/tests/api/v1/test_api_submissions.py @@ -1,6 +1,5 @@ # coding: utf-8 import pytest - from django.conf import settings from rest_framework import status From e245a11f7f6e8857fead6d15c8a86e8381188b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Tue, 10 Sep 2019 10:44:58 -0400 Subject: [PATCH 170/499] Applied last requested changes for PR #2387 - Changed 'id' to `BaseDeploymentBackend.INSTANCE_ID_FIELDNAME` as PK in mock data - Replaced double-quotes with single-quotes in files where changes have been applied - Used triple equals in JS --- kobo/apps/hook/tests/hook_test_case.py | 5 +---- kobo/apps/hook/tests/test_api_hook.py | 5 +---- kobo/apps/hook/tests/test_parser.py | 27 +++----------------------- kpi/renderers.py | 1 + 4 files changed, 6 insertions(+), 32 deletions(-) diff --git a/kobo/apps/hook/tests/hook_test_case.py b/kobo/apps/hook/tests/hook_test_case.py index 4715cec660..3e7ebe8b20 100644 --- a/kobo/apps/hook/tests/hook_test_case.py +++ b/kobo/apps/hook/tests/hook_test_case.py @@ -100,11 +100,8 @@ def _send_and_fail(self): ServiceDefinition = self.hook.get_service_definition() submissions = self.asset.deployment.get_submissions(self.asset.owner.id) -<<<<<<< HEAD + instance_id = submissions[0].get(self.asset.deployment.INSTANCE_ID_FIELDNAME) -======= - instance_id = submissions[0].get("id") ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` service_definition = ServiceDefinition(self.hook, instance_id) first_mock_response = {'error': 'not found'} diff --git a/kobo/apps/hook/tests/test_api_hook.py b/kobo/apps/hook/tests/test_api_hook.py index 46fa45edce..3549f5ba4f 100644 --- a/kobo/apps/hook/tests/test_api_hook.py +++ b/kobo/apps/hook/tests/test_api_hook.py @@ -64,12 +64,9 @@ def test_data_submission(self): hook_signal_url = reverse("hook-signal-list", kwargs={"parent_lookup_asset": self.asset.uid}) submissions = self.asset.deployment.get_submissions(self.asset.owner.id) -<<<<<<< HEAD + data = {"instance_id": submissions[0].get( self.asset.deployment.INSTANCE_ID_FIELDNAME)} -======= - data = {"instance_id": submissions[0].get("id")} ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` response = self.client.post(hook_signal_url, data=data, format='json') self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) diff --git a/kobo/apps/hook/tests/test_parser.py b/kobo/apps/hook/tests/test_parser.py index d4c259f9e0..bac83408df 100644 --- a/kobo/apps/hook/tests/test_parser.py +++ b/kobo/apps/hook/tests/test_parser.py @@ -16,11 +16,8 @@ def test_json_parser(self): ServiceDefinition = hook.get_service_definition() submissions = hook.asset.deployment.get_submissions(hook.asset.owner.id) -<<<<<<< HEAD + uuid = submissions[0].get(hook.asset.deployment.INSTANCE_ID_FIELDNAME) -======= - uuid = submissions[0].get("id") ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` service_definition = ServiceDefinition(hook, uuid) expected_data = { 'group1/q3': u'¿Cómo está en el grupo uno la segunda vez?', @@ -39,11 +36,8 @@ def test_xml_parser(self): self.asset_xml.deploy(backend='mock', active=True) self.asset_xml.save() -<<<<<<< HEAD + hook = self._create_hook(subset_fields=['_id', 'subgroup1', 'q3'], -======= - hook = self._create_hook(subset_fields=["id", "subgroup1", "q3"], ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` format_type=INSTANCE_FORMAT_TYPE_XML) ServiceDefinition = hook.get_service_definition() @@ -55,7 +49,7 @@ def test_xml_parser(self): service_definition = ServiceDefinition(hook, uuid) expected_etree = etree.fromstring( -<<<<<<< HEAD + ('<{asset_uid}>' ' ' ' ¿Cómo está en el grupo uno la segunda vez?' @@ -69,21 +63,6 @@ def test_xml_parser(self): ' ' ' <_id>{id}' '').format( -======= - ("<{asset_uid}>" - " " - " ¿Cómo está en el grupo uno la segunda vez?" - " " - " " - " " - " ¿Cómo está en el subgrupo uno la primera vez?" - " ¿Cómo está en el subgrupo uno la segunda vez?" - " ¿Cómo está en el subgrupo uno la tercera vez?" - " " - " " - " {id}" - "").format( ->>>>>>> Moved the filtering logic of partial permissions into `get_submissions()` asset_uid=self.asset_xml.uid, id=uuid) ) diff --git a/kpi/renderers.py b/kpi/renderers.py index 524d421d83..2cf55ea872 100644 --- a/kpi/renderers.py +++ b/kpi/renderers.py @@ -2,6 +2,7 @@ import json from dicttoxml import dicttoxml +from django.utils.six import text_type from rest_framework import renderers from rest_framework import status from rest_framework.exceptions import ErrorDetail From 11dfa7c90f8795a786a1a693b92dd9328f4171ce Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Sun, 15 Sep 2019 09:39:01 -0400 Subject: [PATCH 171/499] Fix #2407, public checkboxes couldn't be unchecked --- jsapp/js/components/permissions/sharingForm.es6 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jsapp/js/components/permissions/sharingForm.es6 b/jsapp/js/components/permissions/sharingForm.es6 index 29de862225..4b0f04e523 100644 --- a/jsapp/js/components/permissions/sharingForm.es6 +++ b/jsapp/js/components/permissions/sharingForm.es6 @@ -77,7 +77,8 @@ class SharingForm extends React.Component { this.setState({ permissions: parsedPerms, - nonOwnerPerms: nonOwnerPerms + nonOwnerPerms: nonOwnerPerms, + publicPerms: publicPerms }); } From 0087d188567c2fcf3c2473209ea22672fa83809a Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Tue, 17 Sep 2019 21:58:00 -0400 Subject: [PATCH 172/499] WIP - Optimization v2 asset list endpoint - Docstring is missing or incorrect - New methods of ObjectPermission need to be fixed. Content-type is not considered --- dependencies/pip/requirements.in | 3 --- kpi/models/object_permission.py | 5 +++++ kpi/serializers/v2/asset.py | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dependencies/pip/requirements.in b/dependencies/pip/requirements.in index c330c708f1..393e1f00e2 100644 --- a/dependencies/pip/requirements.in +++ b/dependencies/pip/requirements.in @@ -45,10 +45,7 @@ django-private-storage djangorestframework djangorestframework-xml django-redis-sessions -<<<<<<< HEAD django-request-cache -======= ->>>>>>> Use Redis as session storage drf-extensions future geojson-rewind diff --git a/kpi/models/object_permission.py b/kpi/models/object_permission.py index 2d9f75c040..5c3b4c5719 100644 --- a/kpi/models/object_permission.py +++ b/kpi/models/object_permission.py @@ -270,6 +270,11 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): super().delete(*args, **kwargs) + @void_cache_for_request(keys=('__get_all_object_permissions', + '__get_all_user_permissions',)) + def delete(self, *args, **kwargs): + super(self, ObjectPermission).delete(*args, **kwargs) + def __unicode__(self): for required_field in ('user', 'permission'): if not hasattr(self, required_field): diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index a9ce6eb9f2..a089ebd4ff 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -337,11 +337,12 @@ def get_assignable_permissions(self, asset): request=self.context.get('request')), 'label': asset.get_label_for_permission(codename), } - for codename in asset.ASSIGNABLE_PERMISSIONS_BY_TYPE[asset.asset_type]] + for codename in asset.ASSIGNABLE_PERMISSIONS_BY_TYPE[asset.asset_type]] def get_permissions(self, obj): context = self.context request = self.context.get('request') + queryset = ObjectPermissionHelper.get_assignments_queryset(obj, request.user) # Need to pass `asset` and `asset_uid` to context of From a5120bde90dc63310e13a11541e97c500a89d9af Mon Sep 17 00:00:00 2001 From: Olivier Leger Date: Wed, 18 Sep 2019 12:21:24 -0400 Subject: [PATCH 173/499] Added `content_type` filter when retrieve object permissions, improved docstrings. --- kpi/models/object_permission.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kpi/models/object_permission.py b/kpi/models/object_permission.py index 5c3b4c5719..a76eb17966 100644 --- a/kpi/models/object_permission.py +++ b/kpi/models/object_permission.py @@ -11,6 +11,7 @@ from django.core.exceptions import ValidationError, ImproperlyConfigured from django.db import models, transaction from django.shortcuts import _get_queryset +from django.utils.six import string_types from django_request_cache import cache_for_request from kpi.constants import PREFIX_PARTIAL_PERMS From fa3fed0ce0e5dc4e5281c3ed21132096c5249cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Wed, 18 Sep 2019 17:49:11 -0400 Subject: [PATCH 174/499] Added PR#2096 fixes --- kpi/models/asset.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index d0bc45f6fd..4fbd0abe54 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -767,6 +767,9 @@ def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): return perms.get(perm) return None + def get_grant_permissions(self): + return self.permissions.filter(deny=False) + def get_label_for_permission(self, permission_or_codename): try: codename = permission_or_codename.codename From 0ee8ea14377fe6e16da03a7a8cd3a6ca57b22458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Thu, 19 Sep 2019 18:02:02 -0400 Subject: [PATCH 175/499] Optimized Asset List in v2 endpoint WIP - Fixed serializer performance for Asset List in v1 endpoint. --- kpi/models/asset.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 4fbd0abe54..098d1247df 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -768,7 +768,8 @@ def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): return None def get_grant_permissions(self): - return self.permissions.filter(deny=False) + return self.permissions.filter(deny=False).\ + select_related('user', 'permission') def get_label_for_permission(self, permission_or_codename): try: From 3b4836894e21937cafeaf98f6080c660d9418c51 Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Fri, 20 Sep 2019 00:22:58 -0400 Subject: [PATCH 176/499] Optimize filter for grant permissions in API v1 --- kpi/models/asset.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/kpi/models/asset.py b/kpi/models/asset.py index 098d1247df..d0bc45f6fd 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -767,10 +767,6 @@ def get_filters_for_partial_perm(self, user_id, perm=PERM_VIEW_SUBMISSIONS): return perms.get(perm) return None - def get_grant_permissions(self): - return self.permissions.filter(deny=False).\ - select_related('user', 'permission') - def get_label_for_permission(self, permission_or_codename): try: codename = permission_or_codename.codename From bd3aacd23cfad4629a25b82d5ab57e6808583222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 20 Sep 2019 16:10:10 -0400 Subject: [PATCH 177/499] Applied PR#2417 requested changes --- kpi/serializers/v2/asset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index a089ebd4ff..6b32bc3497 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -348,8 +348,8 @@ def get_permissions(self, obj): # Need to pass `asset` and `asset_uid` to context of # AssetPermissionAssignmentSerializer serializer to avoid extra queries to DB # within the serializer to retrieve the asset object. - context.update({'asset': obj}) - context.update({'asset_uid': obj.uid}) + context['asset'] = obj + context['asset_uid'] = obj.uid return AssetPermissionAssignmentSerializer(queryset.all(), many=True, read_only=True, From 0749b5fe38d05232a99017798a074318e69bfd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20L=C3=A9ger?= Date: Fri, 20 Sep 2019 17:09:03 -0400 Subject: [PATCH 178/499] Refactored ObjectPermissionHelper --- kpi/serializers/v2/asset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index 6b32bc3497..4953619fe5 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -343,8 +343,8 @@ def get_permissions(self, obj): context = self.context request = self.context.get('request') - queryset = ObjectPermissionHelper.get_assignments_queryset(obj, - request.user) + queryset = ObjectPermissionHelper. \ + get_user_permission_assignments_queryset(obj, request.user) # Need to pass `asset` and `asset_uid` to context of # AssetPermissionAssignmentSerializer serializer to avoid extra queries to DB # within the serializer to retrieve the asset object. From a23a0299a0abca7539fc43a13993e7452deb7b3f Mon Sep 17 00:00:00 2001 From: duvld Date: Mon, 30 Sep 2019 15:31:37 -0400 Subject: [PATCH 179/499] WIP: UI for delete submission modal created, 405 error when confirming deletion --- jsapp/js/components/table.es6 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index 615016e19c..4401c612ae 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -33,6 +33,14 @@ const NOT_ASSIGNED = 'validation_status_not_assigned'; export const SUBMISSION_LINKS_ID = '__SubmissionLinks'; +const renderCheckbox = (id, label, isImportant) => { + let additionalClass = ''; + if (isImportant) { + additionalClass += 'alertify-toggle-important'; + } + return `
`; +}; + export class DataTable extends React.Component { constructor(props){ super(props); From 57cf3e90239372a5462fb06125d62d3df8676486 Mon Sep 17 00:00:00 2001 From: duvld Date: Mon, 30 Sep 2019 17:26:33 -0400 Subject: [PATCH 180/499] Added UI specified in 2423 - new field does nothing yet --- .../RESTServices/RESTServicesForm.es6 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/jsapp/js/components/RESTServices/RESTServicesForm.es6 b/jsapp/js/components/RESTServices/RESTServicesForm.es6 index f81e904c71..b2c196dffa 100644 --- a/jsapp/js/components/RESTServices/RESTServicesForm.es6 +++ b/jsapp/js/components/RESTServices/RESTServicesForm.es6 @@ -380,6 +380,23 @@ export default class RESTServicesForm extends React.Component { ); } + renderCustomWrapper() { + return( + + + + //Issue 2423: What to do with input value after submission + + + + ) + } + /* * handle fields */ From 670ff6a9bee4a84b2988ec76b1c03c59dc03ec3c Mon Sep 17 00:00:00 2001 From: duvld Date: Mon, 30 Sep 2019 21:26:55 -0400 Subject: [PATCH 181/499] tried implementing live text preview-keep getting null errors --- .../RESTServices/RESTServicesForm.es6 | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/jsapp/js/components/RESTServices/RESTServicesForm.es6 b/jsapp/js/components/RESTServices/RESTServicesForm.es6 index b2c196dffa..f59d9171df 100644 --- a/jsapp/js/components/RESTServices/RESTServicesForm.es6 +++ b/jsapp/js/components/RESTServices/RESTServicesForm.es6 @@ -115,6 +115,17 @@ export default class RESTServicesForm extends React.Component { * helpers */ + amIBeingCalled(){ + console.log('am i being called'); + } + + updatePreview() { + console.log('updatePreview called'); + document.getElementById("chatInput").onkeyup = function() { + document.getElementById("printChatInput").innerHTML = '"' + document.getElementById("chatInput").innerHTML + '": {'; + } + } + getEmptyHeaderRow() { return {name: '', value: ''}; } @@ -381,18 +392,24 @@ export default class RESTServicesForm extends React.Component { } renderCustomWrapper() { + return( - - //Issue 2423: What to do with input value after submission +

Preview:

+ + + + + {/*TODO: Get live updating? Maybe not useful. {this.updatePreview()}*/}
) } @@ -565,7 +582,9 @@ export default class RESTServicesForm extends React.Component { { isEditingExistingHook ? t('Save') : t('Create') } + + ); } } From b5d0217f46c169dd6d108fdf62e2875c252e16d9 Mon Sep 17 00:00:00 2001 From: duvld Date: Tue, 1 Oct 2019 12:06:29 -0400 Subject: [PATCH 182/499] %SUBMISSION% to custom wrapper form --- jsapp/js/components/RESTServices/RESTServicesForm.es6 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jsapp/js/components/RESTServices/RESTServicesForm.es6 b/jsapp/js/components/RESTServices/RESTServicesForm.es6 index f59d9171df..7acf7e8975 100644 --- a/jsapp/js/components/RESTServices/RESTServicesForm.es6 +++ b/jsapp/js/components/RESTServices/RESTServicesForm.es6 @@ -396,7 +396,7 @@ export default class RESTServicesForm extends React.Component { return( -

Preview:

+

Example:

From b60ada7c0c8fe27384564196f832fb40f2a8521d Mon Sep 17 00:00:00 2001 From: duvld Date: Thu, 3 Oct 2019 14:01:38 -0400 Subject: [PATCH 183/499] WIP: getting customWrapper to be sent from frontend to backend --- .../js/components/RESTServices/RESTServicesForm.es6 | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/jsapp/js/components/RESTServices/RESTServicesForm.es6 b/jsapp/js/components/RESTServices/RESTServicesForm.es6 index 7acf7e8975..d5c015c01a 100644 --- a/jsapp/js/components/RESTServices/RESTServicesForm.es6 +++ b/jsapp/js/components/RESTServices/RESTServicesForm.es6 @@ -115,14 +115,10 @@ export default class RESTServicesForm extends React.Component { * helpers */ - amIBeingCalled(){ - console.log('am i being called'); - } - updatePreview() { console.log('updatePreview called'); document.getElementById("chatInput").onkeyup = function() { - document.getElementById("printChatInput").innerHTML = '"' + document.getElementById("chatInput").innerHTML + '": {'; + document.getElementById("printChatInput").innerHTML = '"' + document.getElementById("chatInput").value + '": {'; } } @@ -392,7 +388,7 @@ export default class RESTServicesForm extends React.Component { } renderCustomWrapper() { - + console.log('value: ' + this.state.customWrapper); return(

Example:

From bd61b6a88e1fb33066a5bcb945211bcd526b8f0a Mon Sep 17 00:00:00 2001 From: duvld Date: Mon, 7 Oct 2019 00:52:38 -0400 Subject: [PATCH 184/499] added custom wrapper text input field to RESTservices modal --- jsapp/js/components/RESTServices/RESTServicesForm.es6 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jsapp/js/components/RESTServices/RESTServicesForm.es6 b/jsapp/js/components/RESTServices/RESTServicesForm.es6 index d5c015c01a..79e165a748 100644 --- a/jsapp/js/components/RESTServices/RESTServicesForm.es6 +++ b/jsapp/js/components/RESTServices/RESTServicesForm.es6 @@ -388,7 +388,7 @@ export default class RESTServicesForm extends React.Component { } renderCustomWrapper() { - console.log('value: ' + this.state.customWrapper); + console.log('value: ' + this.state.customWrapper + "name: " + this.state.name); return(
} + {console.log('hello')} {this.renderBackButton()} From e51d76d516997542708e9ae98f858f845a9dd493 Mon Sep 17 00:00:00 2001 From: duvld Date: Fri, 11 Oct 2019 15:17:11 -0400 Subject: [PATCH 196/499] removed console logs --- jsapp/js/components/modalForms/projectSettings.es6 | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/jsapp/js/components/modalForms/projectSettings.es6 b/jsapp/js/components/modalForms/projectSettings.es6 index 8ecc8fb0c4..65b20614e5 100644 --- a/jsapp/js/components/modalForms/projectSettings.es6 +++ b/jsapp/js/components/modalForms/projectSettings.es6 @@ -343,7 +343,6 @@ class ProjectSettings extends React.Component { if (this.state.previousStep) { this.displayStep(this.state.previousStep); } - console.log('step: ' + this.state.previousStep); } /* @@ -578,10 +577,8 @@ class ProjectSettings extends React.Component { this.displayStep(this.STEPS.PROJECT_DETAILS); } }).fail(() => { - console.log('uploading pending in fail, before setting: ' + this.state.isUploadFilePending); this.setState({isUploadFilePending: false}); alertify.error(t('Failed to reload project after upload!')); - console.log('uploading pending in fail, after setting: ' + this.state.isUploadFilePending); }); }, (response) => { @@ -595,21 +592,15 @@ class ProjectSettings extends React.Component { errLines.push(`${response.messages.error_type}: ${escapeHtml(response.messages.error)}`); } alertify.error(errLines.join('
')); - console.log('uploading pending in response: ' + this.state.isUploadFilePending); } ); }, () => { - console.log('uploading pending in otherwise, before setting: ' + this.state.isUploadFilePending); this.setState({isUploadFilePending: false}); - console.log('uploading pending in otherwise, after setting: ' + this.state.isUploadFilePending); alertify.error(t('Could not import XLSForm!')); } ); } - //this.setState({isUploadFilePending: false}); - console.log('uploading pending end: ' + this.state.isUploadFilePending); - } handleSubmit(evt) { @@ -736,7 +727,6 @@ class ProjectSettings extends React.Component { {this.renderLoading(t('Uploading file…'))}
} - {console.log('hello')} {this.renderBackButton()} From 456aa1d56bf44aed1f196b968f08cf4d49d04358 Mon Sep 17 00:00:00 2001 From: duvld Date: Sat, 12 Oct 2019 13:39:51 -0400 Subject: [PATCH 197/499] revmoed 2389 comment, console logs, refactored renderCheckbox to untils --- jsapp/js/components/table.es6 | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index 4401c612ae..edc76e8abc 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -33,14 +33,6 @@ const NOT_ASSIGNED = 'validation_status_not_assigned'; export const SUBMISSION_LINKS_ID = '__SubmissionLinks'; -const renderCheckbox = (id, label, isImportant) => { - let additionalClass = ''; - if (isImportant) { - additionalClass += 'alertify-toggle-important'; - } - return `
`; -}; - export class DataTable extends React.Component { constructor(props){ super(props); @@ -824,7 +816,6 @@ export class DataTable extends React.Component { this.fetchData(this.state.fetchState, this.state.fetchInstance); dialog.destroy(); }).fail((jqxhr) => { - console.error(jqxhr); alertify.error(t('Failed to update status.')); dialog.destroy(); }); @@ -892,7 +883,6 @@ export class DataTable extends React.Component { this.fetchData(this.state.fetchState, this.state.fetchInstance); dialog.destroy(); }).fail((jqxhr) => { - console.error(jqxhr); alertify.error(t('Failed to delete submissions.')); dialog.destroy(); }); From c8f98b91aeddb35aaffc72fc2638ea2b8ffc8689 Mon Sep 17 00:00:00 2001 From: duvld Date: Sun, 13 Oct 2019 02:56:14 -0400 Subject: [PATCH 198/499] fixed typo, brought back console errors --- jsapp/js/components/table.es6 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jsapp/js/components/table.es6 b/jsapp/js/components/table.es6 index edc76e8abc..615016e19c 100644 --- a/jsapp/js/components/table.es6 +++ b/jsapp/js/components/table.es6 @@ -816,6 +816,7 @@ export class DataTable extends React.Component { this.fetchData(this.state.fetchState, this.state.fetchInstance); dialog.destroy(); }).fail((jqxhr) => { + console.error(jqxhr); alertify.error(t('Failed to update status.')); dialog.destroy(); }); @@ -883,6 +884,7 @@ export class DataTable extends React.Component { this.fetchData(this.state.fetchState, this.state.fetchInstance); dialog.destroy(); }).fail((jqxhr) => { + console.error(jqxhr); alertify.error(t('Failed to delete submissions.')); dialog.destroy(); }); From 73220ac8ee17fed484b81c476a8fc0b8bb0e95ea Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Thu, 12 Sep 2019 12:01:51 +0200 Subject: [PATCH 199/499] introduce more useful getSurveyFlatPaths function and test it --- jsapp/js/constants.es6 | 188 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/jsapp/js/constants.es6 b/jsapp/js/constants.es6 index 82d4725022..985f876f6c 100644 --- a/jsapp/js/constants.es6 +++ b/jsapp/js/constants.es6 @@ -145,11 +145,199 @@ new Set([ 'collection' ]).forEach((kind) => {ASSET_KINDS.set(kind, kind);}); +const QUESTION_TYPES = new Map([ + [ + 'select_one', + { + label: t('Select One'), + faIcon: 'fa-dot-circle-o', + id: 'select_one' + } + ], + [ + 'select_multiple', + { + label: t('Select Many'), + faIcon: 'fa-list-ul', + id: 'select_multiple' + } + ], + [ + 'text', + { + label: t('Text'), + faIcon: 'fa-lato-text', + id: 'text' + } + ], + [ + 'integer', + { + label: t('Number'), + faIcon: 'fa-lato-integer', + id: 'integer' + } + ], + [ + 'decimal', + { + label: t('Decimal'), + faIcon: 'fa-lato-decimal', + id: 'decimal' + } + ], + [ + 'date', + { + label: t('Date'), + faIcon: 'fa-calendar', + id: 'date' + } + ], + [ + 'time', + { + label: t('Time'), + faIcon: 'fa-clock-o', + id: 'time' + } + ], + [ + 'datetime', + { + label: t('Date & time'), + faIcon: 'fa-calendar clock-over', + id: 'datetime' + } + ], + [ + 'geopoint', + { + label: t('Point'), + faIcon: 'fa-map-marker', + id: 'geopoint' + } + ], + [ + 'image', + { + label: t('Photo'), + faIcon: 'fa-picture-o', + id: 'image' + } + ], + [ + 'audio', + { + label: t('Audio'), + faIcon: 'fa-volume-up', + id: 'audio' + } + ], + [ + 'video', + { + label: t('Video'), + faIcon: 'fa-video-camera', + id: 'video' + } + ], + [ + 'geotrace', + { + label: t('Line'), + faIcon: 'fa-share-alt', + id: 'geotrace' + } + ], + [ + 'note', + { + label: t('Note'), + faIcon: 'fa-bars', + id: 'note' + } + ], + [ + 'barcode', + { + label: t('Barcode / QR Code'), + faIcon: 'fa-qrcode', + id: 'barcode' + } + ], + [ + 'acknowledge', + { + label: t('Acknowledge'), + faIcon: 'fa-check-square-o', + id: 'acknowledge' + } + ], + [ + 'geoshape', + { + label: t('Area'), + faIcon: 'fa-square', + id: 'geoshape' + } + ], + [ + 'score', + { + label: t('Rating'), + faIcon: 'fa-server', + id: 'score' + } + ], + [ + 'kobomatrix', + { + label: t('Question Matrix'), + faIcon: 'fa-table', + id: 'kobomatrix' + } + ], + [ + 'rank', + { + label: t('Ranking'), + faIcon: 'fa-sort-amount-desc', + id: 'rank' + } + ], + [ + 'calculate', + { + label: t('Calculate'), + faIcon: 'fa-lato-calculate', + id: 'calculate' + } + ], + [ + 'file', + { + label: t('File'), + faIcon: 'fa-file', + id: 'file' + } + ], + [ + 'range', + { + label: t('Range'), + faIcon: 'fa-lato-range', + id: 'range' + } + ] +]); + export default { ROOT_URL: ROOT_URL, ANON_USERNAME: ANON_USERNAME, PERMISSIONS_CODENAMES: PERMISSIONS_CODENAMES, COLLECTION_PERMISSIONS: COLLECTION_PERMISSIONS, + QUESTION_TYPES: QUESTION_TYPES, AVAILABLE_FORM_STYLES: AVAILABLE_FORM_STYLES, update_states: update_states, VALIDATION_STATUSES: VALIDATION_STATUSES, From 14cef0bb44c44bf8e2429d8739f513c382c69ad9 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Sun, 29 Sep 2019 12:56:13 +0200 Subject: [PATCH 200/499] create and use apiTokenDisplay component --- jsapp/js/components/accountSettings.es6 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jsapp/js/components/accountSettings.es6 b/jsapp/js/components/accountSettings.es6 index ea6e9b4b1f..91479782dd 100644 --- a/jsapp/js/components/accountSettings.es6 +++ b/jsapp/js/components/accountSettings.es6 @@ -323,6 +323,10 @@ export default class AccountSettings extends React.Component { + + + + Date: Mon, 30 Sep 2019 15:02:13 +0200 Subject: [PATCH 201/499] finish up api token display --- jsapp/scss/components/_kobo.account-settings.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jsapp/scss/components/_kobo.account-settings.scss b/jsapp/scss/components/_kobo.account-settings.scss index 8e1be30812..74e9b6cd43 100644 --- a/jsapp/scss/components/_kobo.account-settings.scss +++ b/jsapp/scss/components/_kobo.account-settings.scss @@ -25,7 +25,10 @@ .form-modal__item--api-token { input { +<<<<<<< HEAD font-family: monospace; +======= +>>>>>>> finish up api token display width: 50%; } } From 9a7be3eea6aad03d8a56cd8adfa6cec889d870ea Mon Sep 17 00:00:00 2001 From: "John N. Milner" Date: Mon, 21 Oct 2019 17:01:41 -0400 Subject: [PATCH 202/499] Toggle token field btw. `password` and `text` --- jsapp/scss/components/_kobo.account-settings.scss | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jsapp/scss/components/_kobo.account-settings.scss b/jsapp/scss/components/_kobo.account-settings.scss index 74e9b6cd43..710eaaf7b5 100644 --- a/jsapp/scss/components/_kobo.account-settings.scss +++ b/jsapp/scss/components/_kobo.account-settings.scss @@ -25,10 +25,8 @@ .form-modal__item--api-token { input { -<<<<<<< HEAD + font-family: monospace; -======= ->>>>>>> finish up api token display width: 50%; } } From cff28ce9f6013fb0bf123d14b9d5604bc117b5b0 Mon Sep 17 00:00:00 2001 From: Leszek Pietrzak Date: Tue, 8 Oct 2019 12:48:04 +0200 Subject: [PATCH 203/499] rebuild account settings state better --- jsapp/js/components/accountSettings.es6 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jsapp/js/components/accountSettings.es6 b/jsapp/js/components/accountSettings.es6 index 91479782dd..3645f4aa03 100644 --- a/jsapp/js/components/accountSettings.es6 +++ b/jsapp/js/components/accountSettings.es6 @@ -535,7 +535,7 @@ export class ChangePassword extends React.Component { this.validateRequired('currentPassword'); this.validateRequired('newPassword'); this.validateRequired('verifyPassword'); - if (this.state.newPassword != this.state.verifyPassword) { + if (this.state.newPassword !== this.state.verifyPassword) { this.errors['newPassword'] = t('This field must match the Verify Password field.'); } if (Object.keys(this.errors).length === 0) { @@ -638,7 +638,7 @@ export class ChangePassword extends React.Component { /> - +