Repository: akvo/akvo-rsr

Issue:
Creating new organisations from the project editor fails

Map markers fail to load since their location has changed
Map markers fail to load in widgets, etc. (wherever the map `templatetag` is being used).  Their location was changed with the static assets pipeline change. 


Discussion:

Resolving patch:
diff --git a/akvo/rest/views/organisation.py b/akvo/rest/views/organisation.py
--- a/akvo/rest/views/organisation.py
+++ b/akvo/rest/views/organisation.py
@@ -9,8 +9,8 @@
 from django.utils import six
 from rest_framework.decorators import api_view
 from rest_framework.exceptions import ParseError
-from rest_framework.parsers import JSONParser
 from rest_framework.response import Response
+from rest_framework.settings import api_settings
 from rest_framework_xml.parsers import XMLParser
 from rest_framework_xml.compat import etree
 
@@ -74,7 +74,7 @@ class OrganisationViewSet(BaseRSRViewSet):
     """
     queryset = Organisation.objects.all()
     serializer_class = OrganisationSerializer
-    parser_classes = (AkvoOrganisationParser, JSONParser,)
+    parser_classes = [AkvoOrganisationParser] + api_settings.DEFAULT_PARSER_CLASSES
 
 
 @api_view(['GET'])
diff --git a/akvo/rest/views/project_editor.py b/akvo/rest/views/project_editor.py
--- a/akvo/rest/views/project_editor.py
+++ b/akvo/rest/views/project_editor.py
@@ -5,431 +5,32 @@
 For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 """
 
-import datetime
-import decimal
-import json
-
-from akvo.rest.models import TastyTokenAuthentication
-from akvo.rsr.fields import (LatitudeField, LongitudeField,
-                             ProjectLimitedTextField, ValidXMLCharField,
-                             ValidXMLTextField)
-from akvo.rsr.models import (AdministrativeLocation, BudgetItemLabel, Country,
-                             CrsAdd, CrsAddOtherFlag, Fss, FssForecast,
-                             Indicator, IndicatorDimension, IndicatorLabel,
-                             IndicatorPeriod, IndicatorReference,
-                             IndicatorPeriodActualDimension,
-                             IndicatorPeriodActualLocation,
-                             IndicatorPeriodTargetDimension,
-                             IndicatorPeriodTargetLocation, Keyword,
-                             Organisation, Project, OrganisationIndicatorLabel,
-                             ProjectDocument, ProjectDocumentCategory,
-                             ProjectEditorValidationSet, ProjectLocation,
-                             Result, Transaction, TransactionSector)
-from akvo.utils import DjangoModel
-
 from collections import namedtuple
+import json
 
+from django.apps import apps
 from django.contrib.admin.models import LogEntry, CHANGE
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import MultipleObjectsReturned, ValidationError
+from django.core.exceptions import ValidationError
 from django.db import transaction
-from django.apps import apps
-from django.db.models import (BooleanField, DateField, DecimalField, EmailField,
-                              ForeignKey, ManyToManyField, NullBooleanField, PositiveIntegerField,
-                              PositiveSmallIntegerField, URLField)
 from django.http import HttpResponseForbidden, HttpResponseNotFound, HttpResponseBadRequest
 from django.utils.encoding import smart_unicode
 from django.utils.translation import ugettext_lazy as _
-
 from rest_framework import status as http_status
 from rest_framework.decorators import api_view, permission_classes, authentication_classes
 from rest_framework.exceptions import ValidationError as RestValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-
 from sorl.thumbnail import get_thumbnail
 
-
-RELATED_OBJECTS_MAPPING = {
-    # Special mapping for related objects without a 'project' field
-    Indicator: (Result, 'result'),
-    IndicatorLabel: (Indicator, 'indicator'),
-    IndicatorPeriod: (Indicator, 'indicator'),
-    IndicatorReference: (Indicator, 'indicator'),
-    IndicatorDimension: (Indicator, 'indicator'),
-    IndicatorPeriodActualDimension: (IndicatorPeriod, 'period'),
-    IndicatorPeriodActualLocation: (IndicatorPeriod, 'period'),
-    IndicatorPeriodTargetDimension: (IndicatorPeriod, 'period'),
-    IndicatorPeriodTargetLocation: (IndicatorPeriod, 'period'),
-    TransactionSector: (Transaction, 'transaction'),
-    ProjectLocation: (Project, 'location_target'),
-    AdministrativeLocation: (ProjectLocation, 'location'),
-    ProjectDocumentCategory: (ProjectDocument, 'document'),
-    CrsAddOtherFlag: (CrsAdd, 'crs'),
-    FssForecast: (Fss, 'fss'),
-}
-
-MANY_TO_MANY_FIELDS = {
-    # Special mapping for many to many fields
-    Keyword: 'keywords',
-}
-
-
-def add_error(errors, message, field_name):
-    """Appends a new error to the errors list."""
-    if isinstance(message, BaseException):
-        message = message.message
-    errors.append(
-        {'name': field_name,
-         'error': message.encode('utf8').capitalize()}
-    )
-    return errors
-
-
-def log_changes(changes, user, project):
-    """Logs all changes to Django's LogEntry model."""
-    if changes:
-        change_message = u''
-        first_part = u'%s' % _(u'Project editor, changed: ')
-
-        for obj_changes in changes:
-            obj = obj_changes[0]
-            fields = [obj_change[0] for obj_change in obj_changes[1]]
-
-            if not isinstance(obj, Project):
-                obj_change_message = u''
-
-                for field in fields:
-                    obj_change_message += u'%s, ' % field
-
-                LogEntry.objects.log_action(
-                    user_id=user.pk,
-                    content_type_id=ContentType.objects.get_for_model(obj).pk,
-                    object_id=obj.pk,
-                    object_repr=obj.__unicode__(),
-                    action_flag=CHANGE,
-                    change_message=first_part + obj_change_message[:-2] + u'.'
-                )
-
-                change_message += u'%s (id: %s): ' % (type(obj)._meta.verbose_name.capitalize(),
-                                                      str(obj.pk)) + obj_change_message
-
-            else:
-                for field in fields:
-                    change_message += u'%s, ' % field
-
-        change_message = change_message[:-2] + u'.'
-
-        LogEntry.objects.log_action(
-            user_id=user.pk,
-            content_type_id=ContentType.objects.get_for_model(project).pk,
-            object_id=project.pk,
-            object_repr=project.__unicode__(),
-            action_flag=CHANGE,
-            change_message=first_part + change_message
-        )
-
-        field_changes = []
-        for change in changes:
-            for fields in change[1]:
-                if not (fields[1] == 'photo' or 'document-document-' in fields[1]):
-                    field_changes.append([fields[1], fields[2]])
-
-        return field_changes
-
-    return []
-
-
-def split_key(key):
-    """
-    Helper function for splitting the keys of the form data. Key input will be a string like
-    'rsr_relatedproject.relation.1234_new-0' and it will return a KeyInfo namedtuple
-    """
-    KeyParts = namedtuple('KeyParts', 'model, field, ids')
-
-    key_parts = KeyParts._make(key.split('.'))
-    return KeyParts._make([
-        DjangoModel._make([key_parts.model] + key_parts.model.split('_')),
-        key_parts.field,
-        key_parts.ids.split('_')
-    ])
-
-
-def pre_process_data(key, data, errors):
-    """
-    Pre-process the data. Needed to transform some of the form data to usable data in the Django
-    models. Returns the processed data and any errors that have occurred so far.
-    """
-    # Retrieve field information first
-    key_parts = split_key(key)
-    Model = apps.get_model(key_parts.model.app, key_parts.model.model_name)
-    model_field = Model._meta.get_field(key_parts.field)
-
-    # Text data does not need pre-processing
-    if isinstance(model_field, (EmailField, ProjectLimitedTextField, URLField, ValidXMLCharField,
-                                ValidXMLTextField)):
-        return data.strip(), errors
-
-    # Dates should be converted to a datetime object, or None if empty
-    if isinstance(model_field, DateField):
-        if data:
-            try:
-                return datetime.datetime.strptime(data, "%d/%m/%Y").strftime("%Y-%m-%d"), errors
-            except ValueError as e:
-                errors = add_error(errors, e, key)
-                return None, errors
-        else:
-            return None, errors
-
-    # Integers should be converted to an integer
-    if isinstance(model_field, (PositiveIntegerField, PositiveSmallIntegerField)):
-        if data:
-            try:
-                return int(data), errors
-            except ValueError as e:
-                errors = add_error(errors, e, key)
-                return None, errors
-        else:
-            return None, errors
-
-    # Decimals should be converted to a decimal
-    if isinstance(model_field, DecimalField):
-        if data:
-            try:
-                return decimal.Decimal(data), errors
-            except decimal.InvalidOperation as e:
-                if ',' in data:
-                    # Specific error message for commas
-                    e = u'%s' % _(u'It is not allowed to use a comma, use a period to denote '
-                                  u'decimals.')
-                errors = add_error(errors, e, key)
-                return None, errors
-        else:
-            return None, errors
-
-    # Latitude and longitude should be converted to a float
-    if isinstance(model_field, (LatitudeField, LongitudeField)):
-        if data:
-            try:
-                return float(data), errors
-            except ValueError as e:
-                errors = add_error(errors, e, key)
-                return None, errors
-        else:
-            return None, errors
-
-    # Booleans should be converted to True or False
-    if isinstance(model_field, BooleanField):
-        return (True, errors) if data == '1' else (False, errors)
-
-    # Booleans should be converted to True, False or None
-    if isinstance(model_field, NullBooleanField):
-        if data == '1':
-            return True, errors
-        elif data == '2':
-            return False, errors
-        else:
-            return None, errors
-
-    # In case of a foreign key, we first check if this is a project or organisation foreign key.
-    # Then the data should be converted to the related object.
-    if isinstance(model_field, ForeignKey):
-        if data:
-            try:
-                if 'project' in key_parts.field:
-                    return Project.objects.get(pk=int(data)), errors
-                if 'organisation' in key_parts.field:
-                    return Organisation.objects.get(pk=int(data)), errors
-                if key_parts.model.model_name == 'indicatorlabel' and key_parts.field == 'label':
-                    return OrganisationIndicatorLabel.objects.get(pk=int(data)), errors
-                if key_parts.model.model_name == 'budgetitem' and key_parts.field == 'label':
-                    return BudgetItemLabel.objects.get(pk=int(data)), errors
-                if 'country' in key_parts.field:
-                    return Country.objects.get(pk=int(data)), errors
-                return None, errors
-            except (Project.DoesNotExist, Organisation.DoesNotExist, BudgetItemLabel.DoesNotExist,
-                    Country.DoesNotExist, OrganisationIndicatorLabel.DoesNotExist) as e:
-                errors = add_error(errors, e, key)
-                return None, errors
-        else:
-            return None, errors
-
-    # Keywords is the only ManyToManyField
-    if isinstance(model_field, ManyToManyField):
-        try:
-            return Keyword.objects.get(pk=int(data))
-        except Exception as e:
-            errors = add_error(errors, e, key)
-            return None, errors
-
-
-def convert_related_objects(rel_objects):
-    """
-    Converts related objects (db_table without 'rsr_') to the API endpoint so that it can be used
-    in the template.
-    """
-
-    model_to_api = {
-        'relatedproject': 'related_project',
-        'humanitarianscope': 'humanitarian_scope',
-        'projectcontact': 'project_contact',
-        'indicatorperiod': 'indicator_period',
-        'indicatorperiodactualdimension': 'indicator_period_actual_dimension',
-        'indicatorperiodactuallocation': 'indicator_period_actual_location',
-        'indicatorperiodtargetdimension': 'indicator_period_target_dimension',
-        'indicatorperiodtargetlocation': 'indicator_period_target_location',
-        'indicatorreference': 'indicator_reference',
-        'indicatordimension': 'indicator_dimension',
-        'projectcondition': 'project_condition',
-        'budgetitem': 'budget_item',
-        'countrybudgetitem': 'country_budget_item',
-        'transactionsector': 'transaction_sector',
-        'planneddisbursement': 'planned_disbursement',
-        'projectlocation': 'project_location',
-        'administrativelocation': 'administrative_location',
-        'recipientcountry': 'recipient_country',
-        'recipientregion': 'recipient_region',
-        'policymarker': 'policy_marker',
-        'projectdocument': 'project_document',
-        'projectdocumentcategory': 'project_document_category',
-        'crsadd': 'crs_add',
-        'crsaddotherflag': 'crsadd_other_flag',
-        'fssforecast': 'fss_forecast',
-        'legacydata': 'legacy_data',
-    }
-
-    new_rel_objects = []
-
-    for key in rel_objects.keys():
-        # First retrieve the unicode and create a new dict including the unicode
-        db_table, old_key = key.split('.')
-        Model = apps.get_model(db_table.split('_')[0], db_table.split('_')[1])
-        unicode = Model.objects.get(pk=int(rel_objects[key])).__unicode__()
-        new_dict_response = {
-            'new_id': rel_objects[key],
-            'unicode': unicode
-        }
-
-        # remove the 'rsr_' part (e.g. a key can be 'rsr_relatedproject') and look up the db_table
-        # in the mapping, or take the default otherwise
-        db_table = db_table[4:]
-        if db_table in model_to_api.keys():
-            new_dict_response['old_id'] = '{0}.{1}'.format(model_to_api[db_table], old_key)
-        else:
-            new_dict_response['old_id'] = '{0}.{1}'.format(db_table, old_key)
-
-        new_rel_objects.append(new_dict_response)
-
-    return new_rel_objects
-
-
-def add_changes(changes, obj, field, field_name, orig_data):
-    """
-    Add the changes to the changes list in the required format. The changes list contains a list
-    per related object, so we need to check if the object is already in the changes list and
-    append the new changes to it.
-    """
-    if obj not in [change[0] for change in changes]:
-        # Object not yet in changes list
-        changes.append([obj, [[field, field_name, orig_data]]])
-    else:
-        # Object in changes list, look it up and append the new changes
-        for change in changes:
-            if obj == change[0]:
-                change[1].append([field, field_name, orig_data])
-                break
-    return changes
-
-
-def update_object(Model, obj_id, field, obj_data, field_name, orig_data, changes, errors,
-                  rel_objects, related_obj_id):
-    """
-    Update an existing object. First tries to retrieve the object and set the new value of the
-    field, then it performs object and field validations and finally returns the changes or errors
-    of this process.
-    """
-    try:
-        # Retrieve object and set new value of field
-        obj = Model.objects.get(pk=int(obj_id))
-        setattr(obj, field, obj_data)
-    except (Model.DoesNotExist, ValueError) as e:
-        # If object does not exist or 'obj_id' is not an integer, add an error and do not process
-        # the object
-        errors = add_error(errors, str(e), field_name)
-        return changes, errors, rel_objects
-
-    try:
-        # The object has been retrieved, perform validations
-        obj.full_clean(exclude=['primary_location',
-                                'primary_organisation',
-                                'last_update'])
-    except ValidationError as e:
-        if field in dict(e).keys():
-            # Since we save the object per field, display the (first) error of this field on the
-            # field itself.
-            errors = add_error(errors, str(dict(e)[field][0]), field_name)
-        else:
-            # Somewhere else in the model a validation error occurred (or a combination of fields).
-            # We display this nonetheless and do not save the field.
-            errors = add_error(errors, str(e), field_name)
-    except Exception as e:
-        # Just in case any other error will occur, this will also be displayed underneath the field
-        # in the project editor.
-        errors = add_error(errors, str(e), field_name)
-    else:
-        # if the object has a last_modified_at field, include it in the update
-        if hasattr(obj, 'last_modified_at'):
-            update_fields = [field, 'last_modified_at']
-        else:
-            update_fields = [field]
-        # No validation errors. Save the field and append the changes to the changes list.
-        # In case of a non-Project object, add the object to the related objects list, so that the
-        # ID will be replaced (in case of a new object) and the unicode will be replaced.
-        obj.save(update_fields=update_fields)
-        changes = add_changes(changes, obj, field, field_name, orig_data)
-        if not (related_obj_id in rel_objects.keys() or isinstance(obj, Project)):
-            rel_objects[related_obj_id] = obj.pk
-    finally:
-        return changes, errors, rel_objects
-
-
-def create_object(Model, kwargs, field, field_name, orig_data, changes, errors, rel_objects,
-                  related_obj_id):
-    """
-    Create a new object. Either an error can occur while creating the object, or during the
-    full_clean() function. In any case, catch the error and display it in the project editor.
-    """
-    try:
-        # Retrieve the object with the new value and perform validations.
-        obj = Model.objects.create(**kwargs)
-        obj.full_clean()
-    except ValidationError as e:
-        if field in dict(e).keys():
-            # Since we save the object per field, display the (first) error of this field on the
-            # field itself.
-            errors = add_error(errors, str(dict(e)[field][0]), field_name)
-        else:
-            # Somewhere else in the model a validation error occurred (or a combination of fields).
-            # We display this nonetheless and do not save the field.
-            errors = add_error(errors, str(e), field_name)
-        obj.delete()
-    except MultipleObjectsReturned:
-        # Multiple reporting organisations are not allowed and will raise a MultipleObjectsReturned
-        # exception. In this case, display a nice error message and delete the created partnership.
-        message = unicode(_(u'There can be only one reporting organisation'))
-        errors = add_error(errors, str(message), field_name)
-        obj.delete()
-    except Exception as e:
-        # Just in case any other error will occur, this will also be displayed underneath the field
-        # in the project editor.
-        errors = add_error(errors, str(e), field_name)
-    else:
-        # No validation errors. Save the field and append the changes to the changes list.
-        # Add the object to the related objects list, so that the ID and unicode will be replaced.
-        changes = add_changes(changes, obj, field, field_name, orig_data)
-        rel_objects[related_obj_id] = obj.pk
-    finally:
-        return changes, errors, rel_objects
+from akvo.rest.models import TastyTokenAuthentication
+from akvo.rsr.models import (
+    Indicator, IndicatorPeriod, Keyword, Organisation, Project, ProjectEditorValidationSet, Result
+)
+from .project_editor_utils import (
+    convert_related_objects, create_object, create_or_update_objects_from_data, log_changes,
+    split_key, update_object
+)
 
 
 @api_view(['POST'])
@@ -446,152 +47,7 @@ def project_editor(request, pk=None):
 
     # Retrieve form data and set default values
     data = request.POST.copy()
-    errors, changes, rel_objects = [], [], {}
-
-    # Run through the form data 3 times to be sure that all nested objects will be created.
-
-    # Keys like this are possible: 'rsr_indicatorperiod.period_start.1234_new-0_new-0_new-0'
-    # Meaning that there is a new indicator period (the last id is 'new-0'), with a new indicator
-    # (second last id is also 'new-0'), with a new result (second id is also 'new-0'), on an
-    # existing project (project id is '1234').
-
-    # This script runs 4 times if needed, the first time it is at least able to connect the result
-    # to the project and create a result id, which will be stored in rel_objects. The second time
-    # it will definitely be able to create the indicator id, etc.
-
-    for i in range(4):
-        for key in sorted(data.keys()):
-            # The keys in form data are of format "rsr_project.title.1234".
-            # Separated by .'s, the data contains the model name, field name and object id list
-            key_parts = split_key(key)
-
-            # We pre-process the data first. E.g. dates will be converted to datetime objects
-            obj_data, errors = pre_process_data(key, data[key], errors)
-            if key in [error['name'] for error in errors]:
-                data.pop(key, None)
-                continue
-
-            # Retrieve the model and related object ID (e.g. rsr_project.1234)
-            Model = apps.get_model(key_parts.model.app, key_parts.model.model_name)
-            related_obj_id = ''.join(
-                [key_parts.model.table_name, '.', '_'.join(key_parts.ids)]
-            )
-
-            if Model in MANY_TO_MANY_FIELDS.keys():
-                # This field is a many to many field, which need special handling
-                m2m_relation = getattr(project, MANY_TO_MANY_FIELDS[Model])
-                try:
-                    m2m_object = Model.objects.get(pk=int(obj_data))
-                    if len(key_parts.ids) == 1:
-                        # If there already was an appointed object in the many to many relation,
-                        # remove the old object first
-                        old_m2m_object = Model.objects.get(pk=int(key_parts.ids[0]))
-                        if old_m2m_object in m2m_relation.all():
-                            m2m_relation.remove(old_m2m_object)
-                    # Add the new many to many object to the project
-                    m2m_relation.add(m2m_object)
-                    changes = add_changes(changes, m2m_object, key_parts.field, key, obj_data)
-                    if related_obj_id not in rel_objects.keys():
-                        rel_objects[related_obj_id] = obj_data
-                except Model.DoesNotExist as e:
-                    errors = add_error(errors, str(e), key)
-                data.pop(key, None)
-
-            elif len(key_parts.ids) == 1:
-                # Already existing object, update it
-                changes, errors, rel_objects = update_object(
-                    Model, key_parts.ids[0], key_parts.field, obj_data, key, data[key], changes, errors,
-                    rel_objects, related_obj_id
-                )
-                data.pop(key, None)
-
-            else:
-                # New object, with potentially a new parent as well
-                parent_id = '_'.join(key_parts.ids[:-1])
-
-                if 'new' not in parent_id:
-                    # New object, but parent is already existing
-                    parent_obj_id = key_parts.ids[-2]
-
-                    if related_obj_id not in rel_objects.keys():
-                        # Related object has not yet been created (not added to rel_objects dict)
-                        kwargs = dict()
-                        kwargs[key_parts.field] = obj_data
-
-                        if Model in RELATED_OBJECTS_MAPPING.keys():
-                            # Special mapping needed
-                            RelatedModel, related_field = RELATED_OBJECTS_MAPPING[Model]
-                            kwargs[related_field] = RelatedModel.objects.get(pk=parent_obj_id)
-                        else:
-                            # Project is the related object
-                            kwargs['project'] = Project.objects.get(pk=parent_obj_id)
-
-                        # Add field data, create new object and add new id to rel_objects dict
-                        kwargs[key_parts.field] = obj_data
-                        changes, errors, rel_objects = create_object(
-                            Model, kwargs, key_parts.field, key, data[key], changes, errors, rel_objects,
-                            related_obj_id
-                        )
-                        data.pop(key, None)
-                    else:
-                        # Object was already created earlier in this script, update object
-                        changes, errors, rel_objects = update_object(
-                            Model, rel_objects[related_obj_id], key_parts.field, obj_data, key, data[key],
-                            changes, errors, rel_objects, related_obj_id
-                        )
-                        data.pop(key, None)
-
-                else:
-                    # New object, and parent is also new according to the key. However, it is
-                    # possible that the parent was already created earlier in the script. So we
-                    # first check if parent object was already created earlier.
-
-                    RelatedModel, related_field = RELATED_OBJECTS_MAPPING[Model]
-                    if RelatedModel._meta.db_table + '.' + parent_id in rel_objects.keys():
-                        # Parent object has already been created, fetch new parent object id
-                        parent_obj_id = rel_objects[RelatedModel._meta.db_table + '.' + parent_id]
-
-                        if related_obj_id not in rel_objects.keys():
-                            # Related object itself has not yet been created yet
-                            kwargs = dict()
-                            kwargs[key_parts.field] = obj_data
-
-                            if Model in RELATED_OBJECTS_MAPPING.keys():
-                                # Special mapping needed
-                                RelatedModel, related_field = RELATED_OBJECTS_MAPPING[Model]
-                                kwargs[related_field] = RelatedModel.objects.get(pk=parent_obj_id)
-                            else:
-                                # Project is the related object
-                                kwargs['project'] = Project.objects.get(pk=parent_obj_id)
-
-                            # Add field data, create new object and add new id to rel_objects dict
-                            kwargs[key_parts.field] = obj_data
-                            changes, errors, rel_objects = create_object(
-                                Model, kwargs, key_parts.field, key, data[key], changes, errors, rel_objects,
-                                related_obj_id
-                            )
-
-                            data.pop(key, None)
-                        else:
-                            # Related object itself has also been created earlier, update it
-                            changes, errors, rel_objects = update_object(
-                                Model, rel_objects[related_obj_id], key_parts.field, obj_data, key, data[key],
-                                changes, errors, rel_objects, related_obj_id
-                            )
-                            data.pop(key, None)
-
-                    else:
-                        # Parent object has not been created yet. We can't create the underlying
-                        # object without knowing to which parent it should be linked. Therefore the
-                        # key is not popped from the data, and this object will be
-                        # saved in one of the next iterations.
-                        continue
-
-        if not data:
-            # If there are no more keys in data, we have processed all fields and no more iterations
-            # are needed.
-            break
-
+    errors, changes, rel_objects = create_or_update_objects_from_data(project, data)
     # Update the IATI checks for every save in the editor.
     updated_project = Project.objects.get(pk=pk)
     updated_project.update_iati_checks()
@@ -636,7 +92,8 @@ def project_editor_reorder_items(request, project_pk=None):
 
     if not errors:
         # assign order if it doesn't already exist or is messed up
-        if item_list and set(item_list.values_list('order', flat=True)) != set(range(item_list.count())):
+        n_items = item_list.count()
+        if n_items > 0 and set(item_list.values_list('order', flat=True)) != set(range(n_items)):
             for i, item in enumerate(item_list):
                 item.order = i
                 item.save()
@@ -680,9 +137,6 @@ def project_editor_reorder_items(request, project_pk=None):
     )
 
 
-JS_TRUE = 'true'
-
-
 @api_view(['POST'])
 @permission_classes((IsAuthenticated,))
 def project_editor_default_periods(request, project_pk=None):
@@ -772,7 +226,7 @@ def project_editor_upload_file(request, pk=None):
     if len(key_parts.ids) == 1:
         # Either the photo or an already existing project document
         changes, errors, rel_objects = update_object(
-            Model, key_parts.ids[0], key_parts.field, upload_file, field_id, '', changes, errors,
+            Model, key_parts.ids[0], [key_parts.field], [field_id], [upload_file], changes, errors,
             rel_objects, related_obj_id
         )
     else:
@@ -783,8 +237,8 @@ def project_editor_upload_file(request, pk=None):
 
         # Add field data, create new object and add new id to rel_objects dict
         changes, errors, rel_objects = create_object(
-            Model, kwargs, key_parts.field, field_id, '', changes, errors, rel_objects,
-            related_obj_id
+            Model, kwargs, [key_parts.field], [field_id], [upload_file], changes, errors,
+            rel_objects, related_obj_id
         )
 
     for change in changes:
@@ -950,9 +404,9 @@ def project_editor_organisation_logo(request, pk=None):
     data = request.data
     errors, changes, rel_objects = [], [], {}
 
-    if 'logo' in data.keys():
+    if 'logo' in data:
         changes, errors, rel_objects = update_object(
-            Organisation, pk, 'logo', data['logo'], '', '', changes, errors,
+            Organisation, pk, ['logo'], [''], [data['logo']], changes, errors,
             rel_objects, 'rsr_organisation.' + str(pk)
         )
 
diff --git a/akvo/rest/views/project_editor_utils.py b/akvo/rest/views/project_editor_utils.py
new file mode 100644
--- /dev/null
+++ b/akvo/rest/views/project_editor_utils.py
@@ -0,0 +1,661 @@
+# -*- coding: utf-8 -*-
+"""Akvo RSR is covered by the GNU Affero General Public License.
+
+See more details in the license.txt file located at the root folder of the Akvo RSR module.
+For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
+"""
+
+import datetime
+import decimal
+
+from akvo.rsr.fields import (LatitudeField, LongitudeField,
+                             ProjectLimitedTextField, ValidXMLCharField,
+                             ValidXMLTextField)
+from akvo.rsr.models import (AdministrativeLocation, BudgetItemLabel, Country,
+                             CrsAdd, CrsAddOtherFlag, Fss, FssForecast,
+                             Indicator, IndicatorDimension, IndicatorLabel,
+                             IndicatorPeriod, IndicatorReference,
+                             IndicatorPeriodActualDimension,
+                             IndicatorPeriodActualLocation,
+                             IndicatorPeriodTargetDimension,
+                             IndicatorPeriodTargetLocation, Keyword,
+                             Organisation, Project, OrganisationIndicatorLabel,
+                             ProjectDocument, ProjectDocumentCategory, ProjectLocation,
+                             Result, Transaction, TransactionSector)
+from akvo.utils import DjangoModel
+
+from collections import namedtuple
+
+from django.apps import apps
+from django.contrib.admin.models import LogEntry, CHANGE
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import MultipleObjectsReturned, ValidationError
+from django.db.models import (BooleanField, DateField, DecimalField, EmailField,
+                              ForeignKey, ManyToManyField, NullBooleanField, PositiveIntegerField,
+                              PositiveSmallIntegerField, URLField)
+from django.utils.translation import ugettext_lazy as _
+
+
+RELATED_OBJECTS_MAPPING = {
+    # Special mapping for related objects without a 'project' field
+    Indicator: (Result, 'result'),
+    IndicatorLabel: (Indicator, 'indicator'),
+    IndicatorPeriod: (Indicator, 'indicator'),
+    IndicatorReference: (Indicator, 'indicator'),
+    IndicatorDimension: (Indicator, 'indicator'),
+    IndicatorPeriodActualDimension: (IndicatorPeriod, 'period'),
+    IndicatorPeriodActualLocation: (IndicatorPeriod, 'period'),
+    IndicatorPeriodTargetDimension: (IndicatorPeriod, 'period'),
+    IndicatorPeriodTargetLocation: (IndicatorPeriod, 'period'),
+    TransactionSector: (Transaction, 'transaction'),
+    ProjectLocation: (Project, 'location_target'),
+    AdministrativeLocation: (ProjectLocation, 'location'),
+    ProjectDocumentCategory: (ProjectDocument, 'document'),
+    CrsAddOtherFlag: (CrsAdd, 'crs'),
+    FssForecast: (Fss, 'fss'),
+}
+
+MANY_TO_MANY_FIELDS = {
+    # Special mapping for many to many fields
+    Keyword: 'keywords',
+}
+
+
+def add_error(errors, message, field_name):
+    """Appends a new error to the errors list."""
+    if isinstance(message, BaseException):
+        message = message.message
+    errors.append(
+        {'name': field_name,
+         'error': message.encode('utf8').capitalize()}
+    )
+    return errors
+
+
+def log_changes(changes, user, project):
+    """Logs all changes to Django's LogEntry model."""
+    if changes:
+        change_message = u''
+        first_part = u'%s' % _(u'Project editor, changed: ')
+
+        for obj_changes in changes:
+            obj = obj_changes[0]
+            fields = [obj_change[0] for obj_change in obj_changes[1]]
+
+            if not isinstance(obj, Project):
+                obj_change_message = u''
+
+                for field in fields:
+                    obj_change_message += u'%s, ' % field
+
+                LogEntry.objects.log_action(
+                    user_id=user.pk,
+                    content_type_id=ContentType.objects.get_for_model(obj).pk,
+                    object_id=obj.pk,
+                    object_repr=obj.__unicode__(),
+                    action_flag=CHANGE,
+                    change_message=first_part + obj_change_message[:-2] + u'.'
+                )
+
+                change_message += u'%s (id: %s): ' % (type(obj)._meta.verbose_name.capitalize(),
+                                                      str(obj.pk)) + obj_change_message
+
+            else:
+                for field in fields:
+                    change_message += u'%s, ' % field
+
+        change_message = change_message[:-2] + u'.'
+
+        LogEntry.objects.log_action(
+            user_id=user.pk,
+            content_type_id=ContentType.objects.get_for_model(project).pk,
+            object_id=project.pk,
+            object_repr=project.__unicode__(),
+            action_flag=CHANGE,
+            change_message=first_part + change_message
+        )
+
+        field_changes = []
+        for change in changes:
+            for fields in change[1]:
+                if not (fields[1] == 'photo' or 'document-document-' in fields[1]):
+                    field_changes.append([fields[1], fields[2]])
+
+        return field_changes
+
+    return []
+
+
+def split_key(key):
+    """
+    Helper function for splitting the keys of the form data. Key input will be a string like
+    'rsr_relatedproject.relation.1234_new-0' and it will return a KeyInfo namedtuple
+    """
+    KeyParts = namedtuple('KeyParts', 'model, field, ids')
+
+    key_parts = KeyParts._make(key.split('.'))
+    return KeyParts._make([
+        DjangoModel._make([key_parts.model] + key_parts.model.split('_')),
+        key_parts.field,
+        key_parts.ids.split('_')
+    ])
+
+
+def pre_process_data(key, data, errors):
+    """
+    Pre-process the data. Needed to transform some of the form data to usable data in the Django
+    models. Returns the processed data and any errors that have occurred so far.
+    """
+    # Retrieve field information first
+    try:
+        key_parts = split_key(key)
+    except TypeError:
+        return data, errors
+
+    Model = apps.get_model(key_parts.model.app, key_parts.model.model_name)
+    model_field = Model._meta.get_field(key_parts.field)
+
+    # Text data does not need pre-processing
+    if isinstance(model_field, (EmailField, ProjectLimitedTextField, URLField, ValidXMLCharField,
+                                ValidXMLTextField)):
+        return data.strip(), errors
+
+    # Dates should be converted to a datetime object, or None if empty
+    if isinstance(model_field, DateField):
+        if data:
+            try:
+                return datetime.datetime.strptime(data, "%d/%m/%Y").strftime("%Y-%m-%d"), errors
+            except ValueError as e:
+                errors = add_error(errors, e, key)
+                return None, errors
+        else:
+            return None, errors
+
+    # Integers should be converted to an integer
+    if isinstance(model_field, (PositiveIntegerField, PositiveSmallIntegerField)):
+        if data:
+            try:
+                return int(data), errors
+            except ValueError as e:
+                errors = add_error(errors, e, key)
+                return None, errors
+        else:
+            return None, errors
+
+    # Decimals should be converted to a decimal
+    if isinstance(model_field, DecimalField):
+        if data:
+            try:
+                return decimal.Decimal(data), errors
+            except decimal.InvalidOperation as e:
+                if ',' in data:
+                    # Specific error message for commas
+                    e = u'%s' % _(u'It is not allowed to use a comma, use a period to denote '
+                                  u'decimals.')
+                errors = add_error(errors, e, key)
+                return None, errors
+        else:
+            return None, errors
+
+    # Latitude and longitude should be converted to a float
+    if isinstance(model_field, (LatitudeField, LongitudeField)):
+        if data:
+            try:
+                return float(data), errors
+            except ValueError as e:
+                errors = add_error(errors, e, key)
+                return None, errors
+        else:
+            return None, errors
+
+    # Booleans should be converted to True or False
+    if isinstance(model_field, BooleanField):
+        return (True, errors) if data == '1' else (False, errors)
+
+    # Booleans should be converted to True, False or None
+    if isinstance(model_field, NullBooleanField):
+        if data == '1':
+            return True, errors
+        elif data == '2':
+            return False, errors
+        else:
+            return None, errors
+
+    # In case of a foreign key, we first check if this is a project or organisation foreign key.
+    # Then the data should be converted to the related object.
+    if isinstance(model_field, ForeignKey):
+        if data:
+            try:
+                if 'project' in key_parts.field:
+                    return Project.objects.get(pk=int(data)), errors
+                if 'organisation' in key_parts.field:
+                    return Organisation.objects.get(pk=int(data)), errors
+                if key_parts.model.model_name == 'indicatorlabel' and key_parts.field == 'label':
+                    return OrganisationIndicatorLabel.objects.get(pk=int(data)), errors
+                if key_parts.model.model_name == 'budgetitem' and key_parts.field == 'label':
+                    return BudgetItemLabel.objects.get(pk=int(data)), errors
+                if 'country' in key_parts.field:
+                    return Country.objects.get(pk=int(data)), errors
+                return None, errors
+            except (Project.DoesNotExist, Organisation.DoesNotExist, BudgetItemLabel.DoesNotExist,
+                    Country.DoesNotExist, OrganisationIndicatorLabel.DoesNotExist) as e:
+                errors = add_error(errors, e, key)
+                return None, errors
+        else:
+            return None, errors
+
+    # Keywords is the only ManyToManyField
+    if isinstance(model_field, ManyToManyField):
+        try:
+            return Keyword.objects.get(pk=int(data)), errors
+        except Exception as e:
+            errors = add_error(errors, e, key)
+            return None, errors
+
+    return data, errors
+
+
+def convert_related_objects(rel_objects):
+    """
+    Converts related objects (db_table without 'rsr_') to the API endpoint so that it can be used
+    in the template.
+    """
+
+    model_to_api = {
+        'relatedproject': 'related_project',
+        'humanitarianscope': 'humanitarian_scope',
+        'projectcontact': 'project_contact',
+        'indicatorperiod': 'indicator_period',
+        'indicatorperiodactualdimension': 'indicator_period_actual_dimension',
+        'indicatorperiodactuallocation': 'indicator_period_actual_location',
+        'indicatorperiodtargetdimension': 'indicator_period_target_dimension',
+        'indicatorperiodtargetlocation': 'indicator_period_target_location',
+        'indicatorreference': 'indicator_reference',
+        'indicatordimension': 'indicator_dimension',
+        'projectcondition': 'project_condition',
+        'budgetitem': 'budget_item',
+        'countrybudgetitem': 'country_budget_item',
+        'transactionsector': 'transaction_sector',
+        'planneddisbursement': 'planned_disbursement',
+        'projectlocation': 'project_location',
+        'administrativelocation': 'administrative_location',
+        'recipientcountry': 'recipient_country',
+        'recipientregion': 'recipient_region',
+        'policymarker': 'policy_marker',
+        'projectdocument': 'project_document',
+        'projectdocumentcategory': 'project_document_category',
+        'crsadd': 'crs_add',
+        'crsaddotherflag': 'crsadd_other_flag',
+        'fssforecast': 'fss_forecast',
+        'legacydata': 'legacy_data',
+    }
+
+    new_rel_objects = []
+
+    for key in rel_objects:
+        # First retrieve the unicode and create a new dict including the unicode
+        db_table, old_key = key.split('.')
+        Model = apps.get_model(db_table.split('_')[0], db_table.split('_')[1])
+        unicode = Model.objects.get(pk=int(rel_objects[key])).__unicode__()
+        new_dict_response = {
+            'new_id': rel_objects[key],
+            'unicode': unicode
+        }
+
+        # remove the 'rsr_' part (e.g. a key can be 'rsr_relatedproject') and look up the db_table
+        # in the mapping, or take the default otherwise
+        db_table = db_table[4:]
+        if db_table in model_to_api:
+            new_dict_response['old_id'] = '{0}.{1}'.format(model_to_api[db_table], old_key)
+        else:
+            new_dict_response['old_id'] = '{0}.{1}'.format(db_table, old_key)
+
+        new_rel_objects.append(new_dict_response)
+
+    return new_rel_objects
+
+
+def add_changes(changes, obj, field, field_name, orig_data):
+    """
+    Add the changes to the changes list in the required format. The changes list contains a list
+    per related object, so we need to check if the object is already in the changes list and
+    append the new changes to it.
+    """
+    if obj not in [change[0] for change in changes]:
+        # Object not yet in changes list
+        changes.append([obj, [[field, field_name, orig_data]]])
+    else:
+        # Object in changes list, look it up and append the new changes
+        for change in changes:
+            if obj == change[0]:
+                change[1].append([field, field_name, orig_data])
+                break
+    return changes
+
+
+def handle_validation_error(e, fields, field_names, errors):
+    validation_error_dict = dict(e)
+    for field, field_name in zip(fields, field_names):
+        if field in validation_error_dict:
+            # Since we save the object per field, display the (first) error
+            # of this field on the field itself.
+            errors = add_error(errors, str(validation_error_dict[field][0]), field_name)
+            validation_error_dict.pop(field)
+
+    if validation_error_dict:
+        for key, value in validation_error_dict.items():
+            # Somewhere else in the model a validation error occurred (or a
+            # combination of fields). We display this nonetheless and do
+            # not save the field.
+            field, field_name = fields[0], field_names[0]
+            # Guess the correct field_name and display the error
+            actual_field_name = field_name.replace('.{}.'.format(field), '.{}.'.format(key))
+            errors = add_error(errors, str(value[0]), actual_field_name)
+
+
+def update_object(Model, obj_id, fields, field_names, values, changes, errors,
+                  rel_objects, related_obj_id):
+    """Update an existing object.
+
+    Take a list of fields and corresponding values, and update the object. The
+    following steps are performed to do the update:
+
+    1. Try to retrieve the object.
+
+    2. Pre-process the data, ignore fields that have obviously incorrect data
+    (wrong type and cannot be typecasted, etc.)
+
+    3. Set the attributes corresponding to the fields with the supplied values
+
+    4. Perform object and field validations
+
+    5. Return the changes and errors
+
+    """
+
+    # Try to retrieve object with specified ID
+    try:
+        # Retrieve object and set new value of field
+        obj = Model.objects.get(pk=int(obj_id))
+    except (Model.DoesNotExist, ValueError) as e:
+        # If object does not exist or 'obj_id' is not an integer, add an error
+        # and do not process the object
+        for field_name in field_names:
+            errors = add_error(errors, str(e), field_name)
+        return changes, errors, rel_objects
+
+    # Set all the attributes with specified values
+    for i, (field, field_name, value) in enumerate(zip(fields, field_names, values)):
+        obj_data, errors = pre_process_data(field_name, value, errors)
+        if field_name in [error['name'] for error in errors]:
+            fields.pop(i)
+            continue
+        setattr(obj, field, obj_data)
+
+    try:
+        # The object has been retrieved, perform validations
+        obj.full_clean(exclude=['primary_location',
+                                'primary_organisation',
+                                'last_update'])
+    except ValidationError as e:
+        handle_validation_error(e, fields, field_names, errors)
+    except Exception as e:
+        for field_name in field_names:
+            # Just in case any other error will occur, this will also be
+            # displayed underneath the field in the project editor.
+            errors = add_error(errors, str(e), field_name)
+    else:
+        update_fields = fields
+        # if the object has a last_modified_at field, include it in the update
+        if hasattr(obj, 'last_modified_at'):
+            update_fields = fields + ['last_modified_at']
+
+        # No validation errors. Save the field and append the changes to the
+        # changes list. In case of a non-Project object, add the object to the
+        # related objects list, so that the ID will be replaced (in case of a
+        # new object) and the unicode will be replaced.
+        obj.save(update_fields=update_fields)
+        for field, field_name, value in zip(fields, field_names, values):
+            changes = add_changes(changes, obj, field, field_name, value)
+
+        if not (related_obj_id in rel_objects or isinstance(obj, Project)):
+            rel_objects[related_obj_id] = obj.pk
+
+    return changes, errors, rel_objects
+
+
+def update_m2m_object(project, Model, obj_id, field, orig_data, field_name, changes, errors,
+                      rel_objects, related_obj_id):
+
+    m2m_relation = getattr(project, MANY_TO_MANY_FIELDS[Model])
+
+    # We pre-process the data first. E.g. dates will be converted to datetime objects
+    obj_data, errors = pre_process_data(field_name, orig_data, errors)
+    if field_name in [error['name'] for error in errors]:
+        return
+
+    try:
+        m2m_object = Model.objects.get(pk=int(obj_data))
+        if obj_id is not None:
+            # If there already was an appointed object in the many to many relation,
+            # remove the old object first
+            old_m2m_object = Model.objects.get(pk=int(obj_id))
+            if old_m2m_object in m2m_relation.all():
+                m2m_relation.remove(old_m2m_object)
+        # Add the new many to many object to the project
+        m2m_relation.add(m2m_object)
+        changes = add_changes(changes, m2m_object, field, field_name, obj_data)
+        if related_obj_id not in rel_objects:
+            rel_objects[related_obj_id] = obj_data
+
+    except Model.DoesNotExist as e:
+        errors = add_error(errors, str(e), field_name)
+
+
+def create_object(Model, kwargs, fields, field_names, values, changes, errors, rel_objects,
+                  related_obj_id):
+    """Create a new object.
+
+    Either an error can occur while creating the object, or during the
+    full_clean() function. In any case, catch the error and display it in the
+    project editor.
+
+    """
+    try:
+        # Retrieve the object with the new value and perform validations.
+        obj = Model.objects.create(**kwargs)
+        obj.full_clean()
+    except ValidationError as e:
+        handle_validation_error(e, fields, field_names, errors)
+        obj.delete()
+    except MultipleObjectsReturned:
+        # Multiple reporting organisations are not allowed and will raise a MultipleObjectsReturned
+        # exception. In this case, display a nice error message and delete the created partnership.
+        for field_name in field_names:
+            message = unicode(_(u'There can be only one reporting organisation'))
+            # FIXME: Not sure what the field name should be here...
+            errors = add_error(errors, str(message), field_name)
+        obj.delete()
+    except Exception as e:
+        # Just in case any other error will occur, this will also be displayed
+        # underneath the field in the project editor.
+        for field_name in field_names:
+            errors = add_error(errors, str(e), field_name)
+    else:
+        # No validation errors. Save the field and append the changes to the changes list.
+        # Add the object to the related objects list, so that the ID and unicode will be replaced.
+        for field, field_name, value in zip(fields, field_names, values):
+            changes = add_changes(changes, obj, field, field_name, value)
+        rel_objects[related_obj_id] = obj.pk
+
+    return changes, errors, rel_objects
+
+
+def create_related_object(parent_obj_id, Model, fields, field_names, values, changes, errors,
+                          rel_objects, related_obj_id):
+    """Create a related object
+
+    Create a related object with all the values for all the fields. It is
+    called only once per object for each save in the project editor.
+
+    Related objects are created "fully", and never need to be updated post
+    creation, in a single project editor save.
+
+    """
+
+    # Related object has not yet been created (not added to rel_objects dict)
+    kwargs = dict()
+
+    if Model in RELATED_OBJECTS_MAPPING:
+        # Special mapping needed
+        RelatedModel, related_field = RELATED_OBJECTS_MAPPING[Model]
+        kwargs[related_field] = RelatedModel.objects.get(pk=parent_obj_id)
+    else:
+        # Project is the related object
+        kwargs['project'] = Project.objects.get(pk=parent_obj_id)
+
+    # Set all the attributes with specified values
+    for field, field_name, value in zip(fields, field_names, values):
+        obj_data, errors = pre_process_data(field_name, value, errors)
+        if field_name in [error['name'] for error in errors]:
+            continue
+        # Add field data, create new object and add new id to rel_objects dict
+        kwargs[field] = obj_data
+
+    changes, errors, rel_objects = create_object(
+        Model, kwargs, fields, field_names, values, changes, errors, rel_objects,
+        related_obj_id
+    )
+
+    return changes, errors, rel_objects
+
+
+def group_data_by_objects(data):
+    """Group form data by objects (based on model and id)"""
+    grouped_data = {}
+    for key, value in data.items():
+        key_parts = split_key(key)
+        group_key = (key_parts.model.model_name,) + tuple(key_parts.ids)
+        grouped_data.setdefault(group_key, []).append((key, value, key_parts))
+    return grouped_data
+
+
+def group_get_all_fields(grouped_data, key_parts):
+    group_key = (key_parts.model.model_name,) + tuple(key_parts.ids)
+    update_data = grouped_data[group_key]
+    keys = [key for key, _, _ in update_data]
+    values = [value for _, value, _ in update_data]
+    fields = [key_part.field for _, _, key_part in update_data]
+    return fields, values, keys
+
+
+def sort_keys(x):
+    """Compute a level at which the model corresponding to the key occurs.
+
+    This function is used to sort keys in the data such that the objects higher
+    in the hierarchy appear before the objects that depend on them.
+
+    For example, Project -> Result -> Indicator, IndicatorPeriod
+
+    The level is computed based on the number of steps we can take in the
+    RELATED_OBJECTS_MAPPING hierarchy before we reach the Project.
+
+    """
+    key_parts = split_key(x)
+    Model = apps.get_model(key_parts.model.app, key_parts.model.model_name)
+    level = 1
+    while Model in RELATED_OBJECTS_MAPPING:
+        level += 1
+        Model, _ = RELATED_OBJECTS_MAPPING[Model]
+    if Model in MANY_TO_MANY_FIELDS or Model != Project:
+        level += 1
+    return level
+
+
+def create_or_update_objects_from_data(project, data):
+    errors, changes, rel_objects = [], [], {}
+
+    # Keys like this are possible:
+    # 'rsr_indicatorperiod.period_start.1234_new-0_new-0_new-0' Meaning that
+    # there is a new indicator period (the last id is 'new-0'), with a new
+    # indicator (second last id is also 'new-0'), with a new result (second id
+    # is also 'new-0'), on an existing project (project id is '1234'). We sort
+    # the keys in such a way that the result appears before the indicator which
+    # appears before the indicatorperiod. This ensures that objects higher in
+    # the hierarchy, which lower objects depend on, are created first.
+
+    grouped_data = group_data_by_objects(data)
+    sorted_keys = sorted(data.keys(), key=sort_keys)
+
+    for key in sorted_keys:
+
+        # When saving all fields on an object, a bunch of fields are
+        # removed together. This may cause some keys to not be present,
+        # when iterating over the sorted keys.
+        if key not in data:
+            continue
+
+        # The keys in form data are of format "rsr_project.title.1234".
+        # Separated by .'s, the data contains the model name, field name and object id list
+        key_parts = split_key(key)
+
+        # Retrieve the model and related object ID (e.g. rsr_project.1234)
+        Model = apps.get_model(key_parts.model.app, key_parts.model.model_name)
+        related_obj_id = ''.join(
+            [key_parts.model.table_name, '.', '_'.join(key_parts.ids)]
+        )
+
+        if Model in MANY_TO_MANY_FIELDS:
+            # This field is a many to many field, which need special handling
+            obj_id = None if len(key_parts.ids) != 1 else key_parts.ids[0]
+            update_m2m_object(
+                project, Model, obj_id, key_parts.field, data[key], key, changes, errors,
+                rel_objects, related_obj_id
+            )
+            data.pop(key, None)
+
+        elif len(key_parts.ids) == 1:
+            # Already existing object, update it
+            fields, values, keys = group_get_all_fields(grouped_data, key_parts)
+            changes, errors, rel_objects = update_object(
+                Model, key_parts.ids[0], fields, keys, values, changes, errors, rel_objects,
+                related_obj_id
+            )
+            for key in keys:
+                data.pop(key, None)
+
+        else:
+            # New object, with potentially a new parent as well
+            parent_id = '_'.join(key_parts.ids[:-1])
+
+            if 'new' not in parent_id:
+                # New object, but parent is already existing
+                parent_obj_id = key_parts.ids[-2]
+
+            else:
+                # New object, and parent are new according to the key.
+                # However, it is possible that the parent was already
+                # created earlier in the script. So we also check if
+                # the parent object was already created earlier.
+                ParentModel, _ = RELATED_OBJECTS_MAPPING[Model]
+                parent_obj_rel_obj_key = ParentModel._meta.db_table + '.' + parent_id
+                if parent_obj_rel_obj_key in rel_objects:
+                    parent_obj_id = rel_objects[parent_obj_rel_obj_key]
+                else:
+                    parent_obj_id = None
+
+            if parent_obj_id is not None:
+                fields, values, keys = group_get_all_fields(grouped_data, key_parts)
+                create_related_object(
+                    parent_obj_id, Model, fields, keys, values, changes, errors, rel_objects,
+                    related_obj_id
+                )
+                for key in keys:
+                    data.pop(key, None)
+
+            else:
+                # Parent object has not been created yet.
+                # We should never get to this state!
+                raise RuntimeError('There was a problem walking the hierarchy of objects')
+
+    return errors, changes, rel_objects


Test patch:
diff --git a/akvo/rsr/tests/rest/test_project_editor.py b/akvo/rsr/tests/rest/test_project_editor.py
--- a/akvo/rsr/tests/rest/test_project_editor.py
+++ b/akvo/rsr/tests/rest/test_project_editor.py
@@ -7,16 +7,23 @@
 For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 """
 import datetime
+from tempfile import NamedTemporaryFile
+from urllib import urlencode
+
 from django.conf import settings
 from django.contrib.auth.models import Group
 from django.test import TestCase, Client
+from mock import patch
 
-from akvo.rest.views.project_editor import add_error, split_key
+from akvo.rest.views.project_editor_utils import (
+    add_error, create_or_update_objects_from_data, split_key
+)
 from akvo.rsr.iso3166 import ISO_3166_COUNTRIES
 from akvo.rsr.models import (
     BudgetItem, BudgetItemLabel, Country, Employment, Indicator, IndicatorLabel, Organisation,
     OrganisationIndicatorLabel, Partnership, Project, ProjectLocation, Result, User,
-    RelatedProject, IndicatorPeriod)
+    RelatedProject, IndicatorPeriod, Keyword
+)
 from akvo.rsr.templatetags.project_editor import choices
 from akvo.utils import check_auth_groups, DjangoModel
 
@@ -555,6 +562,71 @@ def create_countries():
             )
 
 
+class UploadFileTestCase(TestCase):
+    """Test that uploading a file works correctly."""
+
+    def setUp(self):
+        self.project = Project.objects.create(title='New Project')
+        self.user, self.username, self.password = create_user()
+        self.c = Client(HTTP_HOST=settings.RSR_DOMAIN)
+        self.c.login(username=self.username, password=self.password)
+
+    def test_uploading_new_file(self):
+        # Given
+        id_ = self.project.id
+        url = '/rest/v1/project/{}/upload_file/?format=json'.format(id_)
+        with open(__file__) as f:
+            data = {
+                'field_id': 'rsr_projectdocument.document.{}_new-0'.format(id_),
+                'file': f
+            }
+
+            # When
+            response = self.c.post(url, data=data, follow=True)
+
+        # Then
+        self.assertEqual(200, response.status_code)
+        errors = response.data['errors']
+        self.assertEqual(0, len(errors))
+        changes = response.data['changes']
+        self.assertEqual(1, len(changes))
+        upload_url = changes[0][1]
+        resp = self.c.get(upload_url, follow=True)
+        text = '\n'.join(resp.streaming_content)
+        self.assertIn(self.__class__.__name__, text)
+
+    def test_replacing_existing_file(self):
+        # Given
+        id_ = self.project.id
+        url = '/rest/v1/project/{}/upload_file/?format=json'.format(id_)
+        with open(__file__) as f:
+            data = {
+                'field_id': 'rsr_projectdocument.document.{}_new-0'.format(id_),
+                'file': f
+            }
+            response = self.c.post(url, data=data, follow=True)
+        document_id = response.data['rel_objects'][0]['new_id']
+        with NamedTemporaryFile() as f:
+            new_data = {
+                'field_id': 'rsr_projectdocument.document.{}'.format(document_id),
+                'file': f
+            }
+
+            # When
+            response = self.c.post(url, data=new_data, follow=True)
+
+        # Then
+        self.assertEqual(200, response.status_code)
+        errors = response.data['errors']
+        self.assertEqual(0, len(errors))
+        changes = response.data['changes']
+        self.assertEqual(1, len(changes))
+        upload_url = changes[0][1]
+        resp = self.c.get(upload_url, follow=True)
+        text = '\n'.join(resp.streaming_content)
+        self.assertNotIn(self.__class__.__name__, text)
+
+
 class DefaultPeriodsTestCase(TestCase):
     """Test the adding and removal of default periods."""
 
@@ -685,3 +757,293 @@ def test_set_default_periods_when_periods_exist(self):
         self.assertEqual(child_periods1.count(), 3)
         child_periods2 = IndicatorPeriod.objects.filter(indicator__result__project=self.child_project2)
         self.assertEqual(child_periods2.count(), 3)
+
+
+class CreateOrUpdateTestCase(TestCase):
+
+    def setUp(self):
+        # Create necessary groups
+        check_auth_groups(settings.REQUIRED_AUTH_GROUPS)
+
+        # Create project
+        self.project = Project.objects.create(title="Test Project")
+        self.project_2 = Project.objects.create(title="Test Project 2")
+
+    def test_saving_project_attributes(self):
+        # Given
+        iati_activity_id = u'iati_activity_id'
+        background = u'Background'
+        data = {
+            u'rsr_project.iati_activity_id.{}'.format(self.project.id): iati_activity_id,
+            u'rsr_project.background.{}'.format(self.project.id): background,
+        }
+
+        # When
+        errors, changes, rel_objects = create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        project = Project.objects.get(id=self.project.id)
+        self.assertEqual(project.iati_activity_id, iati_activity_id)
+        self.assertEqual(project.background, background)
+        self.assertEqual(0, len(errors))
+        self.assertEqual(0, len(rel_objects))
+        self.assertEqual(1, len(changes))
+        self.assertEqual(2, len(changes[0]))
+        self.assertEqual(2, len(changes[0][1]))
+
+    def test_save_called_once(self):
+        # Given
+        iati_activity_id = u'iati_activity_id'
+        background = u'Background'
+        data = {
+            u'rsr_project.iati_activity_id.{}'.format(self.project.id): iati_activity_id,
+            u'rsr_project.background.{}'.format(self.project.id): background,
+        }
+
+        # When
+        with patch('akvo.rsr.models.project.Project.save') as patched_save:
+            create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        self.assertEqual(1, patched_save.call_count)
+
+    def test_saving_incorrect_project_attributes(self):
+        # Given
+        hierarchy = 'incorrect_hierarchy_value'
+        data = {
+            u'rsr_project.hierarchy.{}'.format(self.project.id): hierarchy,
+        }
+
+        # When
+        errors, changes, rel_objects = create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        self.assertEqual(1, len(errors))
+        self.assertEqual('rsr_project.hierarchy.{}'.format(self.project.id), errors[0]['name'])
+        self.assertEqual(0, len(rel_objects))
+        self.assertEqual(0, len(changes))
+
+    def test_creating_project_attribute_object(self):
+        # Given
+        relation = '3'
+        data = {
+            u'rsr_relatedproject.relation.{}_new-1'.format(self.project.id): relation,
+            u'rsr_relatedproject.related_project.{}_new-1'.format(self.project.id): self.project_2.id,
+        }
+
+        # When
+        errors, changes, rel_objects = create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        project = Project.objects.get(id=self.project.id)
+        self.assertEqual(project.related_projects.count(), 1)
+        related_project = project.related_projects.first()
+        self.assertEqual(related_project.project, self.project)
+        self.assertEqual(related_project.related_project, self.project_2)
+        self.assertEqual(related_project.relation, relation)
+        self.assertEqual(0, len(errors))
+        self.assertEqual(1, len(rel_objects))
+        self.assertEqual(1, len(changes))
+        self.assertEqual(2, len(changes[0]))
+        self.assertEqual(2, len(changes[0][1]))
+
+    def test_create_project_attribute_object_with_all_attributes(self):
+        # Given
+        relation = '3'
+        data = {
+            u'rsr_relatedproject.relation.{}_new-1'.format(self.project.id): relation,
+            u'rsr_relatedproject.related_project.{}_new-1'.format(self.project.id): self.project_2.id,
+        }
+
+        # When
+        update_object_path = 'akvo.rest.views.project_editor_utils.update_object'
+        with patch(update_object_path, return_value=([], [], [])) as patched_save:
+            create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        self.assertEqual(patched_save.call_count, 0)
+        project = Project.objects.get(id=self.project.id)
+        self.assertEqual(project.related_projects.count(), 1)
+        related_project = project.related_projects.first()
+        self.assertEqual(related_project.project, self.project)
+        self.assertEqual(related_project.related_project, self.project_2)
+        self.assertEqual(related_project.relation, relation)
+
+    def test_creating_project_m2m_object(self):
+        # Given
+        keyword_label = 'keyword-1'
+        keyword = Keyword(label=keyword_label)
+        keyword.save()
+        keyword_label_2 = 'keyword-2'
+        keyword_2 = Keyword(label=keyword_label_2)
+        keyword_2.save()
+        data = {
+            u'rsr_keyword.label.{}_new-1'.format(self.project.id): str(keyword.id),
+            u'rsr_keyword.label.{}_new-2'.format(self.project.id): str(keyword_2.id)
+        }
+
+        # When
+        errors, changes, rel_objects = create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        project = Project.objects.get(id=self.project.id)
+        self.assertEqual(project.keywords.count(), 2)
+        keyword = project.keywords.first()
+        self.assertEqual(keyword_label, keyword.label)
+        self.assertEqual(0, len(errors))
+        self.assertEqual(2, len(rel_objects))
+        self.assertEqual(2, len(changes))
+        self.assertEqual(2, len(changes[0]))
+        self.assertEqual(1, len(changes[0][1]))
+        self.assertEqual(2, len(changes[1]))
+        self.assertEqual(1, len(changes[1][1]))
+
+    def test_updating_project_attribute_object(self):
+        # Given
+        sibling_relation = '3'
+        parent_relation = '1'
+        original_data = {
+            u'rsr_relatedproject.relation.{}_new-1'.format(self.project.id): sibling_relation,
+            u'rsr_relatedproject.related_project.{}_new-1'.format(self.project.id): self.project_2.id,
+        }
+        create_or_update_objects_from_data(self.project, original_data)
+
+        # When
+        project = Project.objects.get(id=self.project.id)
+        update_data = {
+            u'rsr_relatedproject.relation.{}'.format(project.related_projects.first().id): '1'
+        }
+        errors, changes, rel_objects = create_or_update_objects_from_data(self.project, update_data)
+
+        # Then
+        project = Project.objects.get(id=self.project.id)
+        self.assertEqual(project.related_projects.count(), 1)
+        related_project = project.related_projects.first()
+        self.assertEqual(related_project.project, self.project)
+        self.assertEqual(related_project.related_project, self.project_2)
+        self.assertEqual(related_project.relation, parent_relation)
+        self.assertEqual(0, len(errors))
+        self.assertEqual(1, len(rel_objects))
+        self.assertEqual(1, len(changes))
+        self.assertEqual(2, len(changes[0]))
+        self.assertEqual(1, len(changes[0][1]))
+
+    def test_creating_project_attirbute_hierarchy(self):
+        # Given
+        result_title = 'Result Title'
+        result_description = 'Result Description'
+        result_type = u'1'
+        result_aggregation = u'2'
+
+        result_title_2 = 'Result Title 2'
+        result_description_2 = 'Result Description 2'
+        result_type_2 = u'2'
+        result_aggregation_2 = u'2'
+
+        indicator_title = u'Indicator Title'
+        indicator_description = u'Indicator Description'
+        indicator_measure = '1'
+        indicator_type = '1'
+        indicator_ascending = '1'
+
+        period_start = u'01/01/2019'
+        period_end = u'01/02/2019'
+        period_target_value = u'12'
+        period_target_comment = u'Target Comment'
+
+        data = {
+            u'rsr_result.description.{}_new-0': result_description,
+            u'rsr_result.title.{}_new-0': result_title,
+            u'rsr_result.type.{}_new-0': result_type,
+            u'rsr_result.aggregation_status.{}_new-0': result_aggregation,
+
+            u'rsr_result.description.{}_new-1': result_description_2,
+            u'rsr_result.title.{}_new-1': result_title_2,
+            u'rsr_result.type.{}_new-1': result_type_2,
+            u'rsr_result.aggregation_status.{}_new-1': result_aggregation_2,
+
+            u'rsr_indicator.type.{}_new-0_new-0': indicator_type,
+            u'rsr_indicator.ascending.{}_new-0_new-0': indicator_ascending,
+            u'rsr_indicator.title.{}_new-0_new-0': indicator_title,
+            u'rsr_indicator.description.{}_new-0_new-0': indicator_description,
+            u'rsr_indicator.measure.{}_new-0_new-0': indicator_measure,
+
+            u'rsr_indicatorperiod.period_start.{}_new-0_new-0_new-0': period_start,
+            u'rsr_indicatorperiod.period_end.{}_new-0_new-0_new-0': period_end,
+            u'rsr_indicatorperiod.target_value.{}_new-0_new-0_new-0': period_target_value,
+            u'rsr_indicatorperiod.target_comment.{}_new-0_new-0_new-0': period_target_comment,
+        }
+        data = {
+            key.format(self.project.id): value for key, value in data.items()
+        }
+
+        # When
+        errors, changes, rel_objects = create_or_update_objects_from_data(self.project, data)
+
+        # Then
+        project = Project.objects.get(id=self.project.id)
+        self.assertEqual(2, project.results.count())
+
+        result = Result.objects.get(project=self.project.id, title=result_title)
+        self.assertEqual(result.title, result_title)
+        self.assertEqual(result.description, result_description)
+        self.assertEqual(result.type, result_type)
+        self.assertEqual(result.aggregation_status, result_aggregation == '1')
+
+        result_2 = Result.objects.get(project=self.project.id, title=result_title_2)
+        self.assertEqual(result_2.title, result_title_2)
+        self.assertEqual(result_2.description, result_description_2)
+        self.assertEqual(result_2.type, result_type_2)
+        self.assertEqual(result_2.aggregation_status, result_aggregation_2 == '1')
+
+        indicator = Indicator.objects.get(result=result)
+        self.assertEqual(indicator.title, indicator_title)
+        self.assertEqual(indicator.description, indicator_description)
+        self.assertEqual(indicator.type, int(indicator_type))
+        self.assertEqual(indicator.measure, indicator_measure)
+        self.assertEqual(indicator.ascending, indicator_ascending == '1')
+
+        period = IndicatorPeriod.objects.get(indicator=indicator)
+        self.assertEqual(period.period_start.strftime('%d/%m/%Y'), period_start)
+        self.assertEqual(period.period_end.strftime('%d/%m/%Y'), period_end)
+        self.assertEqual(period.target_value, period_target_value)
+        self.assertEqual(period.target_comment, period_target_comment)
+
+        self.assertEqual(0, len(errors))
+        self.assertEqual(4, len(rel_objects))
+        self.assertEqual(4, len(changes))
+        for change in changes:
+            self.assertEqual(2, len(change))
+            attributes = 5 if isinstance(change[0], Indicator) else 4
+            self.assertEqual(attributes, len(change[1]))
+
+
+class CreateNewOrganisationTestCase(TestCase):
+
+    def setUp(self):
+        super(CreateNewOrganisationTestCase, self).setUp()
+        self.user, self.username, self.password = create_user()
+        self.org = Organisation.objects.create(name='Akvo', long_name='Akvo')
+        Employment.objects.create(
+            user=self.user,
+            organisation=self.org,
+            group=Group.objects.get(name='Admins'),
+            is_approved=True,
+        )
+        self.c = Client(HTTP_HOST=settings.RSR_DOMAIN)
+
+    def test_create_new_organisation(self):
+        # Given
+        self.c.login(username=self.username, password=self.password)
+        url = '/rest/v1/organisation/?format=json'
+        content_type = 'application/x-www-form-urlencoded'
+        data = {'name': 'Test Org', 'long_name': 'Test Organisation'}
+
+        # When
+        response = self.c.post(url, data=urlencode(data), content_type=content_type)
+
+        # Then
+        self.assertTrue(response.status_code, 201)
+        self.assertIn('id', response.data)
+        for key in data:
+            self.assertEqual(response.data[key], data[key])
diff --git a/akvo/rsr/tests/test_templatetags.py b/akvo/rsr/tests/test_templatetags.py
--- a/akvo/rsr/tests/test_templatetags.py
+++ b/akvo/rsr/tests/test_templatetags.py
@@ -6,13 +6,16 @@
 For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 """
 
-from django.test import TestCase
+from django.conf import settings
+from django.contrib.staticfiles.finders import FileSystemFinder
 
+from akvo.rsr.tests.base import BaseTestCase
 from akvo.rsr.models import ProjectEditorValidation, ProjectEditorValidationSet
 from akvo.rsr.templatetags.project_editor import mandatory_or_hidden, invalidate_validation_cache
+from akvo.rsr.templatetags import maps
 
 
-class TemplateTagsTestCase(TestCase):
+class TemplateTagsTestCase(BaseTestCase):
     """Tests for template tags."""
 
     @classmethod
@@ -145,3 +148,24 @@ def test_update_subtitle_validation(self):
         )
         expected_indications = 'mandatory-{id_1} mandatory-{id_2}'.format(**self.ids)
         self.assertIndications(field, expected_indications)
+
+
+class MapsTestCase(BaseTestCase):
+    """Test case for the maps templatetags"""
+
+    def test_maps_markers_exist(self):
+        # Given
+        icons = [
+            getattr(maps, attr) for attr in dir(maps) if attr.endswith('_ICON')
+        ]
+        finder = FileSystemFinder()
+        relative_paths = [
+            icon.lstrip(settings.STATIC_URL) for icon in icons
+        ]
+
+        # When
+        absolute_paths = [finder.find(path) for path in relative_paths]
+
+        # Then
+        for path in absolute_paths:
+            self.assertTrue(path)