kobotoolbox / kpi

kpi is the (frontend) server for KoboToolbox. It includes an API for users to access data and manage their forms, question library, sharing settings, create reports, and export data.
https://www.kobotoolbox.org
GNU Affero General Public License v3.0
131 stars 176 forks source link

Row level data sharing #2259

Closed noliveleger closed 4 years ago

noliveleger commented 5 years ago

Three levels of permission:

  1. See all submitted data
  2. See data submitted by a subset of users only
  3. See data submitted by oneself only

Front-end thingy: https://jsfiddle.net/tur3xbzy/ Notes: https://hackmd.io/CyWe_tjVSqWfQiA2wlBS9w

jnm commented 5 years ago

Copying partial permissions probably isn't handled yet on the backend. From @magicznyleszek:

  1. create form A
  2. assign a partial_submissions permission in form A
  3. create form B
  4. do "Copy Team Permissions" from form A to form B
kpi.exceptions.BadPermissionsException
BadPermissionsException: Can not assign 'partial_submissions' permission. Partial permissions are missing.

Traceback (most recent call last)
File "/usr/local/lib/python2.7/dist-packages/django/contrib/staticfiles/handlers.py", line 63, in __call__
                    return debug.technical_404_response(request, e)
        return super(StaticFilesHandler, self).get_response(request)

    def __call__(self, environ, start_response):
        if not self._should_handle(get_path_info(environ)):
            return self.application(environ, start_response)
        return super(StaticFilesHandler, self).__call__(environ, start_response)
File "/usr/local/lib/python2.7/dist-packages/whitenoise/base.py", line 66, in __call__
        if self.autorefresh:
            static_file = self.find_file(path)
        else:
            static_file = self.files.get(path)
        if static_file is None:
            return self.application(environ, start_response)
        else:
            return self.serve(static_file, environ, start_response)

    def serve(self, static_file, environ, start_response):
        response = static_file.get_response(environ['REQUEST_METHOD'], environ)
File "/usr/local/lib/python2.7/dist-packages/django/core/handlers/wsgi.py", line 189, in __call__
                    'status_code': 400,
                }
            )
            response = http.HttpResponseBadRequest()
        else:
            response = self.get_response(request)

        response._handler_class = self.__class__

        status = '%s %s' % (response.status_code, response.reason_phrase)
        response_headers = [(str(k), str(v)) for k, v in response.items()]
File "/usr/local/lib/python2.7/dist-packages/django/core/handlers/base.py", line 218, in get_response
            raise

        except:  # Handle everything else.
            # Get the exception info now, in case another exception is thrown later.
            signals.got_request_exception.send(sender=self.__class__, request=request)
            response = self.handle_uncaught_exception(request, resolver, sys.exc_info())

        try:
            # Apply response middleware, regardless of the response
            for middleware_method in self._response_middleware:
                response = middleware_method(request, response)
File "/usr/local/lib/python2.7/dist-packages/django/core/handlers/base.py", line 261, in handle_uncaught_exception
                'request': request
            }
        )

        if settings.DEBUG:
            return debug.technical_500_response(request, *exc_info)

        # If Http500 handler is not installed, re-raise last exception
        if resolver.urlconf_module is None:
            six.reraise(*exc_info)
        # Return an HttpResponse that displays a friendly error message.
File "/usr/local/lib/python2.7/dist-packages/django_extensions/management/technical_response.py", line 6, in null_technical_500_response
# coding=utf-8
import six

def null_technical_500_response(request, exc_type, exc_value, tb, status_code=500):
    six.reraise(exc_type, exc_value, tb)
File "/usr/local/lib/python2.7/dist-packages/django/core/handlers/base.py", line 132, in get_response
                        break

            if response is None:
                wrapped_callback = self.make_view_atomic(callback)
                try:
                    response = wrapped_callback(request, *callback_args, **callback_kwargs)
                except Exception as e:
                    # If the view raised an exception, run it through exception
                    # middleware, and if the exception middleware returns a
                    # response, use that. Otherwise, reraise the exception.
                    for middleware_method in self._exception_middleware:
File "/usr/local/lib/python2.7/dist-packages/django/views/decorators/csrf.py", line 58, in wrapped_view
    """
    # We could just do view_func.csrf_exempt = True, but decorators
    # are nicer if they don't have side-effects, so we return a new
    # function.
    def wrapped_view(*args, **kwargs):
        return view_func(*args, **kwargs)
    wrapped_view.csrf_exempt = True
    return wraps(view_func, assigned=available_attrs(view_func))(wrapped_view)
File "/usr/local/lib/python2.7/dist-packages/rest_framework/viewsets.py", line 90, in view
            self.request = request
            self.args = args
            self.kwargs = kwargs

            # And continue as usual
            return self.dispatch(request, *args, **kwargs)

        # take name and docstring from class
        update_wrapper(view, cls, updated=())

        # and possible attributes set by decorators
File "/usr/local/lib/python2.7/dist-packages/rest_framework/views.py", line 489, in dispatch
                handler = self.http_method_not_allowed

            response = handler(request, *args, **kwargs)

        except Exception as exc:
            response = self.handle_exception(exc)

        self.response = self.finalize_response(request, response, *args, **kwargs)
        return self.response

    def options(self, request, *args, **kwargs):
File "/usr/local/lib/python2.7/dist-packages/rest_framework/views.py", line 449, in handle_exception

        context = self.get_exception_handler_context()
        response = exception_handler(exc, context)

        if response is None:
            self.raise_uncaught_exception(exc)

        response.exception = True
        return response

    def raise_uncaught_exception(self, exc):
File "/usr/local/lib/python2.7/dist-packages/rest_framework/views.py", line 486, in dispatch
                handler = getattr(self, request.method.lower(),
                                  self.http_method_not_allowed)
            else:
                handler = self.http_method_not_allowed

            response = handler(request, *args, **kwargs)

        except Exception as exc:
            response = self.handle_exception(exc)

        self.response = self.finalize_response(request, response, *args, **kwargs)
File "/srv/src/kpi/kpi/views/v1/asset.py", line 177, in permissions
        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()

File "/usr/local/lib/python2.7/dist-packages/django/utils/decorators.py", line 145, in inner
        """
        def __call__(self, func):
            @wraps(func, assigned=available_attrs(func))
            def inner(*args, **kwargs):
                with self:
                    return func(*args, **kwargs)
            return inner
File "/srv/src/kpi/kpi/models/object_permission.py", line 306, in copy_permissions_from
            source_permissions = list(source_object.permissions.all())
            for source_permission in source_permissions:
                self.assign_perm(
                    user_obj=source_permission.user,
                    perm=source_permission.permission.codename,
                    deny=source_permission.deny)
            self._recalculate_inherited_perms()
            return True
        else:
            return False

File "/usr/local/lib/python2.7/dist-packages/django/utils/decorators.py", line 145, in inner
        """
        def __call__(self, func):
            @wraps(func, assigned=available_attrs(func))
            def inner(*args, **kwargs):
                with self:
                    return func(*args, **kwargs)
            return inner
File "/srv/src/kpi/kpi/models/object_permission.py", line 747, in assign_perm
        # We might have be
jnm commented 5 years ago

Probably should allow the frontend to send an array of permissions, per https://www.flowdock.com/app/kobotoolbox/kobo/threads/IvxocwYJJiQ8tB3ek2o5RDGY0FV

jnm commented 5 years ago

Nested collections permission endpoint, to match assets? https://www.flowdock.com/app/kobotoolbox/kobo/threads/wWTcr4IPet8pHT5zokWoi099eUZ

Done!

noliveleger commented 5 years ago

Probably should allow the frontend to send an array of permissions, per https://www.flowdock.com/app/kobotoolbox/kobo/threads/IvxocwYJJiQ8tB3ek2o5RDGY0FV

Done!

noliveleger commented 5 years ago

Copying partial permissions probably isn't handled yet on the backend. From @magicznyleszek:

Fixed