示例#1
0
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',
        )
示例#2
0
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)
    description = serializers.CharField()
    date_created = serializers.ReadOnlyField()
    content = SerializerMethodFileField(allow_empty_file=True, required=False)
    metadata = WritableJSONField(required=False)

    class Meta:
        model = AssetFile
        fields = (
            'uid',
            'url',
            'asset',
            'user',
            'user__username',
            'file_type',
            'description',
            'date_created',
            'content',
            'metadata',
        )

    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))

    def to_internal_value(self, data):
        """
        Overrides parent method to add base64 encoded string to validated data
        if it exists.
        """
        ret = super().to_internal_value(data)
        # `base64Encoded` is not a valid field, thus it is discarded by Django
        # validation process. We add it here to be able to access it in
        # `.validate()` and use our custom validation.
        try:
            ret['base64Encoded'] = data['base64Encoded']
        except KeyError:
            pass
        return ret

    def validate(self, attr):
        self.__file_type = attr['file_type']  # noqa

        metadata = self._get_metadata(attr.get('metadata'))
        validated_field = self._validate_media_content_method(attr, metadata)
        # Call the validator related to `validated_field`, either:
        # - `self._validate_content()`
        # - `self._validate_base64Encoded()`
        # - `self._validate_redirect_url()`
        validator = getattr(self, f'_validate_{validated_field}')
        validator(attr, metadata)

        # Common validators
        filename = metadata['filename']
        self.__validate_mime_type(filename, validated_field)
        self._validate_duplicate(filename, validated_field)

        # Remove `'base64Encoded'` from attributes passed to the model
        attr.pop('base64Encoded', None)

        return attr

    def _get_metadata(self, metadata: Union[str, dict]) -> dict:
        """
        `metadata` parameter can be sent as a stringified JSON or in pure JSON.
        """
        if not isinstance(metadata, dict):
            try:
                metadata = json.loads(metadata)
                logging.warning('metadata is sent as stringified JSON')
            except TypeError:
                # Let the validator returns an explicit message to user that
                # `metadata` is required
                pass
            except ValueError:
                raise serializers.ValidationError(
                    {'metadata': _('JSON is invalid')})

        return metadata

    def _validate_base64Encoded(self, attr: dict, metadata: dict):  # noqa
        base64_encoded = attr['base64Encoded']
        metadata = self._validate_metadata(metadata)

        try:
            media_content = base64_encoded[base64_encoded.index('base64') + 7:]
        except ValueError:
            raise serializers.ValidationError(
                {'base64Encoded': _('Invalid content')})

        attr.update({
            'content':
            ContentFile(base64.decodebytes(media_content.encode()),
                        name=metadata['filename'])
        })

    def _validate_content(self, attr: dict, metadata: dict):
        try:
            attr['content']
        except KeyError:
            raise serializers.ValidationError(
                {'content': _('No files have been submitted')})

        metadata['filename'] = attr['content'].name

    def _validate_duplicate(self, filename: str, field_name: str):

        if self.__file_type == AssetFile.FORM_MEDIA:
            view = self.context.get('view')
            asset = getattr(view, 'asset', self.context.get('asset'))
            # File name must be unique
            if AssetFile.objects.filter(asset=asset,
                                        date_deleted__isnull=True,
                                        metadata__filename=filename).exists():
                error = self.__format_error(field_name,
                                            _('File already exists'))
                raise serializers.ValidationError(error)

    def _validate_media_content_method(self, attr: dict,
                                       metadata: dict) -> str:
        """
        Validates whether user only uses one of the available methods to
        save a media file:
        - Binary upload
        - Base64 encoded string
        - Remote URL

        Raises an `ValidationError` otherwise

        Returns:
            str: 'content', 'base64Encoded', 'redirect_url'
        """
        methods = []
        try:
            metadata['redirect_url']
        except (TypeError, KeyError):
            pass
        else:
            methods.append('redirect_url')

        try:
            attr['base64Encoded']
        except KeyError:
            pass
        else:
            methods.append('base64Encoded')

        try:
            attr['content']
        except KeyError:
            # if no other methods are used, force binary upload for later
            # validation
            if not methods:
                methods.append('content')
        else:
            methods.append('content')

        # Only one method should be present
        if len(methods) == 1:
            return methods[0]

        raise serializers.ValidationError({
            'detail':
            _('You cannot upload media file with two different ways at '
              'the same time. Please choose between binary upload, base64'
              ' or remote URL.')
        })

    def _validate_metadata(self,
                           metadata: dict,
                           validate_redirect_url: bool = False) -> dict:

        if metadata is None:
            raise serializers.ValidationError(
                {'metadata': _('This field is required')})

        if validate_redirect_url:
            try:
                metadata['redirect_url']
            except KeyError:
                raise serializers.ValidationError(
                    {'metadata': _('`redirect_url` is required')})
            else:
                parsed_url = urlparse(metadata['redirect_url'])
                metadata['filename'] = os.path.basename(parsed_url.path)

        try:
            metadata['filename']
        except KeyError:
            raise serializers.ValidationError(
                {'metadata': _('`filename` is required')})

        return metadata

    def _validate_redirect_url(self, attr: dict, metadata: dict):
        metadata = self._validate_metadata(metadata,
                                           validate_redirect_url=True)

        redirect_url = metadata['redirect_url']
        validator = URLValidator()
        try:
            validator(redirect_url)
        except (AttributeError, DjangoValidationError):
            raise serializers.ValidationError(
                {'metadata': _('`redirect_url` is invalid')})

    # PRIVATE METHODS
    # These methods could be protected too but IMO, they should not be
    # overridden if a class inherits from `AssetFileSerializer`
    def __format_error(self, field_name: str, message: str):
        """
        Format validation error to return explicit field name.
        For example, if `redirect_url` is being validated, we
        want to return `metadata` field to be consistent with the payload
        """
        if field_name == 'redirect_url':
            field_name = 'metadata'
            message = f'{message}'

        return {field_name: message}

    def __validate_mime_type(self, filename: str, field_name: str):
        """
        Validates MIME type of the file depending on its type
        (`form_media` or `media_layer`)
        """
        # Check if content type is allowed
        try:
            allowed_mime_types = AssetFile.ALLOWED_MIME_TYPES[self.__file_type]
        except KeyError:
            pass
        else:
            mime_type, encoding_ = guess_type(filename)
            if not mime_type or not mime_type.startswith(allowed_mime_types):
                mime_types_csv = '`, `'.join(allowed_mime_types)
                error = self.__format_error(
                    field_name,
                    _('Only `{}` MIME types are allowed').format(
                        mime_types_csv))
                raise serializers.ValidationError(error)
class AssetSnapshotSerializer(serializers.HyperlinkedModelSerializer):
    url = 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)

    class Meta:
        model = AssetSnapshot
        lookup_field = 'uid'
        fields = ('url',
                  'uid',
                  'owner',
                  'date_created',
                  'xml',
                  'enketopreviewlink',
                  'asset',
                  'asset_version_id',
                  'details',
                  'source',
                  )

    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

        if source:
            snapshot = AssetSnapshot.objects.create(**validated_data)
        else:
            # asset.snapshot pulls, by default, a snapshot for the latest
            # version.
            snapshot = asset.snapshot

        if not snapshot.xml:
            raise serializers.ValidationError(snapshot.details)
        return snapshot

    def get_enketopreviewlink(self, obj):
        return reverse(
            viewname='assetsnapshot-preview',
            kwargs={'uid': obj.uid},
            request=self.context.get('request', None)
        )

    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.
        :param obj: AssetSnapshot
        :return: str
        """
        return reverse(
            viewname='assetsnapshot-detail',
            format='xml',
            kwargs={'uid': obj.uid},
            request=self.context.get('request', None)
        )

    def validate(self, attrs):

        user = self.context['request'].user

        asset = attrs.get('asset', False)
        source = attrs.get('source', False)

        if not any((asset, source)):
            raise serializers.ValidationError({
                'asset': 'Specify an asset and/or a source'
            })

        if asset:
            if not user.has_perm(PERM_VIEW_ASSET, asset):
                # The client is not allowed to snapshot this asset
                raise exceptions.PermissionDenied
            if not source:
                attrs.pop('source', None)
        elif source:
            # The client provided source directly; no need to copy anything
            # For tidiness, pop off unused fields. `None` avoids KeyError
            attrs.pop('asset', None)
            attrs.pop('asset_version', None)

        return attrs
示例#4
0
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 constance.config.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().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)

        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)
        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()

        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)

        return super().update(instance, validated_data)
示例#5
0
class AssetSerializer(serializers.HyperlinkedModelSerializer):

    owner = RelativePrefixHyperlinkedRelatedField(view_name='user-detail',
                                                  lookup_field='username',
                                                  read_only=True)
    owner__username = serializers.ReadOnlyField(source='owner.username')
    url = 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=Asset.objects.filter(asset_type=ASSET_TYPE_COLLECTION),
        view_name='asset-detail',
        required=False,
        allow_null=True)
    assignable_permissions = serializers.SerializerMethodField()
    permissions = serializers.SerializerMethodField()
    exports = serializers.SerializerMethodField()
    export_settings = serializers.SerializerMethodField()
    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()
    data = serializers.SerializerMethodField()

    # Only add link instead of hooks list to avoid multiple access to DB.
    hooks_link = serializers.SerializerMethodField()

    children = serializers.SerializerMethodField()
    subscribers_count = serializers.SerializerMethodField()
    status = serializers.SerializerMethodField()
    access_types = serializers.SerializerMethodField()
    data_sharing = WritableJSONField(required=False)
    paired_data = serializers.SerializerMethodField()

    class Meta:
        model = Asset
        lookup_field = 'uid'
        fields = (
            'url',
            'owner',
            'owner__username',
            'parent',
            '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',
            'assignable_permissions',
            'permissions',
            'exports',
            'export_settings',
            'settings',
            'data',
            'children',
            'subscribers_count',
            'status',
            'access_types',
            'data_sharing',
            'paired_data',
        )
        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({'translations': str(err)})
            validated_data['content'] = asset_content
        return super().update(asset, validated_data)

    def get_fields(self, *args, **kwargs):
        fields = super().get_fields(*args, **kwargs)
        # 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,
            }

        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_data(self, obj):
        kwargs = {'parent_lookup_asset': obj.uid}
        format = self.context.get('format')
        if format:
            kwargs['format'] = format

        return reverse('submission-list',
                       kwargs=kwargs,
                       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

        try:
            request = self.context['request']
            user = request.user
            if obj.owner_id == user.id:
                return obj.deployment.submission_count

            # `has_perm` benefits from internal calls which use
            # `django_cache_request`. It won't hit DB multiple times
            if obj.has_perm(user, PERM_VIEW_SUBMISSIONS):
                return obj.deployment.submission_count

            if obj.has_perm(user, PERM_PARTIAL_SUBMISSIONS):
                return obj.deployment.calculated_submission_count(user=user)
        except KeyError:
            pass

        return 0

    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_children(self, asset):
        """
        Handles the detail endpoint but also takes advantage of the
        `AssetViewSet.get_serializer_context()` "cache" for the list endpoint,
        if it is present
        """
        if asset.asset_type != ASSET_TYPE_COLLECTION:
            return {'count': 0}

        try:
            children_count_per_asset = self.context['children_count_per_asset']
        except KeyError:
            children_count = asset.children.count()
        else:
            children_count = children_count_per_asset.get(asset.pk, 0)

        return {'count': children_count}

    def get_subscribers_count(self, asset):
        if asset.asset_type != ASSET_TYPE_COLLECTION:
            return 0
        # ToDo Optimize this. What about caching it inside `summary`
        return UserAssetSubscription.objects.filter(asset_id=asset.pk).count()

    def get_status(self, asset):

        # `order_by` lets us check `AnonymousUser`'s permissions first.
        # No need to read all permissions if `AnonymousUser`'s permissions
        # are found.
        # We assume that `settings.ANONYMOUS_USER_ID` equals -1.
        perm_assignments = asset.permissions. \
            values('user_id', 'permission__codename'). \
            exclude(user_id=asset.owner_id). \
            order_by('user_id', 'permission__codename')

        return self._get_status(perm_assignments)

    def get_paired_data(self, asset):
        request = self.context.get('request')
        return reverse('paired-data-list', args=(asset.uid, ), request=request)

    def get_permissions(self, obj):
        context = self.context
        request = self.context.get('request')

        queryset = 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.
        context['asset'] = obj
        context['asset_uid'] = obj.uid

        return AssetPermissionAssignmentSerializer(queryset.all(),
                                                   many=True,
                                                   read_only=True,
                                                   context=context).data

    def get_exports(self, obj: Asset) -> str:
        return reverse(
            'asset-export-list',
            args=(obj.uid, ),
            request=self.context.get('request', None),
        )

    def get_export_settings(self, obj: Asset) -> ReturnList:
        return AssetExportSettingsSerializer(
            AssetExportSettings.objects.filter(asset=obj),
            many=True,
            read_only=True,
            context=self.context,
        ).data

    def get_access_types(self, obj):
        """
        Handles the detail endpoint but also takes advantage of the
        `AssetViewSet.get_serializer_context()` "cache" for the list endpoint,
        if it is present
        """
        # Avoid extra queries if obj is not a collection
        if obj.asset_type != ASSET_TYPE_COLLECTION:
            return None

        # User is the owner
        try:
            request = self.context['request']
        except KeyError:
            return None

        access_types = []
        if request.user == obj.owner:
            access_types.append('owned')

        # User can view the collection.
        try:
            # The list view should provide a cache
            asset_permission_assignments = self.context[
                'object_permissions_per_asset'].get(obj.pk)
        except KeyError:
            asset_permission_assignments = obj.permissions.all()

        # We test at the same time whether the collection is public or not
        for obj_permission in asset_permission_assignments:

            if (not obj_permission.deny
                    and obj_permission.user_id == settings.ANONYMOUS_USER_ID
                    and obj_permission.permission.codename
                    == PERM_DISCOVER_ASSET):
                access_types.append('public')

                if request.user == obj.owner:
                    # Do not go further, `access_type` cannot be `shared`
                    # and `owned`
                    break

            if (request.user != obj.owner and not obj_permission.deny
                    and obj_permission.user == request.user):
                access_types.append('shared')
                # Do not go further, we assume `settings.ANONYMOUS_USER_ID`
                # equals -1. Thus, `public` access type should be discovered at
                # first
                break

        # User has subscribed to this collection
        subscribed = False
        try:
            # The list view should provide a cache
            subscriptions = self.context['user_subscriptions_per_asset'].get(
                obj.pk, [])
        except KeyError:
            subscribed = obj.has_subscribed_user(request.user.pk)
        else:
            subscribed = request.user.pk in subscriptions
        if subscribed:
            access_types.append('subscribed')

        # User is big brother.
        if request.user.is_superuser:
            access_types.append('superuser')

        if not access_types:
            raise Exception(
                f'{request.user.username} has unexpected access to {obj.uid}')

        return access_types

    def validate_data_sharing(self, data_sharing: dict) -> dict:
        """
        Validates `data_sharing`. It is really basic.
        Only the type of each property is validated. No data is validated.
        It is consistent with partial permissions and REST services.

        The client bears the responsibility of providing valid data.
        """
        errors = {}
        if not self.instance or not data_sharing:
            return data_sharing

        if 'enabled' not in data_sharing:
            errors['enabled'] = t('The property is required')

        if 'fields' in data_sharing:
            if not isinstance(data_sharing['fields'], list):
                errors['fields'] = t('The property must be an array')
            else:
                asset = self.instance
                fields = data_sharing['fields']
                form_pack, _unused = build_formpack(asset,
                                                    submission_stream=[])
                valid_fields = [
                    f.path for f in form_pack.get_fields_for_versions(
                        form_pack.versions.keys())
                ]
                unknown_fields = set(fields) - set(valid_fields)
                if unknown_fields and valid_fields:
                    errors['fields'] = t(
                        'Some fields are invalid, '
                        'choices are: `{valid_fields}`').format(
                            valid_fields='`,`'.join(valid_fields))
        else:
            data_sharing['fields'] = []

        if errors:
            raise serializers.ValidationError(errors)

        return data_sharing

    def validate_parent(self, parent: Asset) -> Asset:
        user = get_database_user(self.context['request'].user)
        # Validate first if user can update the current parent
        if self.instance and self.instance.parent is not None:
            if not self.instance.parent.has_perm(user, PERM_CHANGE_ASSET):
                raise serializers.ValidationError(
                    t('User cannot update current parent collection'))

        # Target collection is `None`, no need to check permissions
        if parent is None:
            return parent

        # `user` must have write access to target parent before being able to
        # move the asset.
        parent_perms = parent.get_perms(user)
        if PERM_VIEW_ASSET not in parent_perms:
            raise serializers.ValidationError(t('Target collection not found'))

        if PERM_CHANGE_ASSET not in parent_perms:
            raise serializers.ValidationError(
                t('User cannot update target parent collection'))

        return parent

    def _content(self, obj):
        return json.dumps(obj.content)

    def _get_status(self, perm_assignments):
        """
        Returns asset status.

        **Asset's owner's permissions must be excluded from `perm_assignments`**

        Args:
            perm_assignments (list): List of dicts `{<user_id>, <codename}`
                                     ordered by `user_id`
                                     e.g.: [{-1, 'view_asset'},
                                            {2, 'view_asset'}]

        Returns:
            str: Status slug among these:
                 - 'private'
                 - 'public'
                 - 'public-discoverable'
                 - 'shared'

        """
        if not perm_assignments:
            return ASSET_STATUS_PRIVATE

        for perm_assignment in perm_assignments:
            if perm_assignment.get('user_id') == settings.ANONYMOUS_USER_ID:
                if perm_assignment.get(
                        'permission__codename') == PERM_DISCOVER_ASSET:
                    return ASSET_STATUS_DISCOVERABLE

                if perm_assignment.get(
                        'permission__codename') == PERM_VIEW_ASSET:
                    return ASSET_STATUS_PUBLIC

            return ASSET_STATUS_SHARED

    def _table_url(self, obj):
        request = self.context.get('request', None)
        return reverse('asset-table-view', args=(obj.uid, ), request=request)
示例#6
0
文件: data.py 项目: kobotoolbox/kpi
class DataBulkActionsValidator(serializers.Serializer):
    """
    The purpose of this class is to benefit from the DRF validation mechanism
    without reinventing the wheel.
    It is used to validate the bulk actions payload and to pass a correctly
    formatted dictionary to the deployment back end.
    """
    payload = WritableJSONField()

    def __init__(self, instance=None, data=empty, **kwargs):
        self.__perm = kwargs.pop('perm', None)
        super().__init__(instance=instance, data=data, **kwargs)

    def validate_payload(self, payload: dict) -> dict:
        try:
            payload['submission_ids']
        except KeyError:
            self.__validate_query(payload)
        else:
            self.__validate_submission_ids(payload)
            if self.__perm == PERM_CHANGE_SUBMISSIONS:
                self.__validate_updated_data(payload)

        if self.__perm == PERM_VALIDATE_SUBMISSIONS:
            self.__validate_validation_status(payload)

        return payload

    def to_representation(self, instance):
        return {
            'submission_ids': instance['payload'].get('submission_ids', []),
            'query': instance['payload'].get('query', {}),
            'data': instance['payload'].get('data'),
            'validation_status.uid': instance['payload'].get(
                'validation_status.uid'),
            'confirm': instance['payload'].get('confirm'),
        }

    def __validate_query(self, payload: dict):

        # If `query` is not provided, it means that all submissions should
        # be altered. In that case, `confirm=True` should be passed among
        # the parameters to validate the action
        try:
            payload['query']
        except KeyError:
            if not payload.get('confirm', False):
                raise serializers.ValidationError(
                    t('Confirmation is required')
                )

    def __validate_submission_ids(self, payload: dict):
        try:
            # Ensuring submission ids are integer values and unique
            submission_ids = [int(id_) for id_ in set(payload['submission_ids'])]
        except ValueError:
            raise serializers.ValidationError(
                t('`submission_ids` must only contain integer values')
            )

        if len(submission_ids) == 0:
            raise serializers.ValidationError(
                t('`submission_ids` must contain at least one value')
            )

        payload['submission_ids'] = submission_ids

    def __validate_updated_data(self, payload: dict):
        if not payload.get('data'):
            raise serializers.ValidationError(
                t('`data` is required')
            )

    def __validate_validation_status(self, payload: dict):
        try:
            payload['validation_status.uid']
        except KeyError:
            raise serializers.ValidationError(
                t('`validation_status.uid` is required')
            )
class AssetExportSettingsSerializer(serializers.ModelSerializer):
    uid = serializers.ReadOnlyField()
    url = serializers.SerializerMethodField()
    name = serializers.CharField(allow_blank=True)
    date_modified = serializers.CharField(read_only=True)
    export_settings = WritableJSONField()

    class Meta:
        model = AssetExportSettings
        fields = (
            'uid',
            'url',
            'name',
            'date_modified',
            'export_settings',
        )
        read_only_fields = (
            'uid',
            'url',
            'date_modified',
        )

    def validate_export_settings(self, export_settings: dict) -> dict:
        asset = self.context['view'].asset
        asset_languages = asset.summary.get('languages', [])
        all_valid_languages = [*asset_languages, *VALID_DEFAULT_LANGUAGES]

        for required in REQUIRED_EXPORT_SETTINGS:
            if required not in export_settings:
                raise serializers.ValidationError(
                    _(
                        "`export_settings` must contain all the following "
                        "required keys: {}"
                    ).format(
                        format_exception_values(REQUIRED_EXPORT_SETTINGS, 'and')
                    )
                )

        for key in export_settings:
            if key not in VALID_EXPORT_SETTINGS:
                raise serializers.ValidationError(
                    _(
                        "`export_settings` can contain only the following "
                        "valid keys: {}"
                    ).format(
                        format_exception_values(VALID_EXPORT_SETTINGS, 'and')
                    )
                )

        if (
            export_settings[EXPORT_SETTING_MULTIPLE_SELECT]
            not in VALID_MULTIPLE_SELECTS
        ):
            raise serializers.ValidationError(
                _("`multiple_select` must be either {}").format(
                    format_exception_values(VALID_MULTIPLE_SELECTS)
                )
            )

        if export_settings[EXPORT_SETTING_TYPE] not in VALID_EXPORT_TYPES:
            raise serializers.ValidationError(
                _("`type` must be either {}").format(
                    format_exception_values(VALID_EXPORT_TYPES)
                )
            )

        if (
            export_settings[EXPORT_SETTING_HIERARCHY_IN_LABELS]
            and len(export_settings[EXPORT_SETTING_GROUP_SEP]) == 0
        ):
            raise serializers.ValidationError(
                _('`group_sep` must be a non-empty value')
            )

        if export_settings[EXPORT_SETTING_LANG] not in all_valid_languages:
            raise serializers.ValidationError(
                _("`lang` for this asset must be either {}").format(
                    format_exception_values(all_valid_languages)
                )
            )

        if EXPORT_SETTING_FIELDS not in export_settings:
            return export_settings

        fields = export_settings[EXPORT_SETTING_FIELDS]
        if not isinstance(fields, list):
            raise serializers.ValidationError(_('`fields` must be an array'))

        if not all((isinstance(field, str) for field in fields)):
            raise serializers.ValidationError(
                _('All values in the `fields` array must be strings')
            )

        # `flatten` is used for geoJSON exports only and is ignored otherwise
        if EXPORT_SETTING_FLATTEN not in export_settings:
            return export_settings

        return export_settings

    def get_url(self, obj: Asset) -> str:
        return reverse(
            'asset-export-settings-detail',
            args=(obj.asset.uid, obj.uid),
            request=self.context.get('request', None),
        )
示例#8
0
文件: asset.py 项目: iMMAP/ccpm-kpi
class AssetSerializer(serializers.HyperlinkedModelSerializer):

    owner = RelativePrefixHyperlinkedRelatedField(view_name='user-detail',
                                                  lookup_field='username',
                                                  read_only=True)
    owner__username = serializers.ReadOnlyField(source='owner.username')
    url = 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')
    assignable_permissions = serializers.SerializerMethodField()
    permissions = serializers.SerializerMethodField()
    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()
    data = 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',
            'assignable_permissions',
            'permissions',
            'settings',
            'data',
        )
        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(str(err))
            validated_data['content'] = asset_content
        return super().update(asset, validated_data)

    def get_fields(self, *args, **kwargs):
        fields = super().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,
            }

        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_data(self, obj):
        kwargs = {'parent_lookup_asset': obj.uid}
        format = self.context.get('format')
        if format:
            kwargs['format'] = format

        return reverse('submission-list',
                       kwargs=kwargs,
                       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

        try:
            request = self.context['request']
            user = request.user
            if obj.owner_id == user.id:
                return obj.deployment.submission_count

            # `has_perm` benefits from internal calls which use
            # `django_cache_request`. It won't hit DB multiple times
            if obj.has_perm(user, PERM_VIEW_SUBMISSIONS):
                return obj.deployment.submission_count

            if obj.has_perm(user, PERM_PARTIAL_SUBMISSIONS):
                return obj.deployment.calculated_submission_count(
                    requesting_user_id=user.id)
        except KeyError:
            pass

        return 0

    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')

        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.
        context['asset'] = obj
        context['asset_uid'] = obj.uid

        return AssetPermissionAssignmentSerializer(queryset.all(),
                                                   many=True,
                                                   read_only=True,
                                                   context=context).data

    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)
示例#9
0
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 constance.config.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().to_representation(obj)
        if (
            not rep['extra_details']
            or not isinstance(rep['extra_details'], dict)
        ):
            rep['extra_details'] = {}
        extra_details = rep['extra_details']

        # the front end used to set `primarySector` but has since been changed
        # to `sector`, which matches the registration form
        if (
            extra_details.get('primarySector')
            and not extra_details.get('sector')
        ):
            extra_details['sector'] = extra_details['primarySector']

        # remove `primarySector` to avoid confusion
        try:
            del extra_details['primarySector']
        except KeyError:
            pass

        # the registration form records only the value, but the front end
        # expects an object with both the label and the value.
        # TODO: store and load the value *only*
        for field in 'sector', 'country':
            val = extra_details.get(field)
            if isinstance(val, str) and val:
                extra_details[field] = {
                    'label': val,
                    'value': val,
                }

        # `require_auth` needs to be read from KC every time
        # except during testing, when KC's database is not available
        if (
            settings.KOBOCAT_URL
            and settings.KOBOCAT_INTERNAL_URL
            and not settings.TESTING
        ):
            extra_details['require_auth'] = get_kc_profile_data(obj.pk).get(
                'require_auth', False
            )

        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': t('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: t('`current_password` and `new_password` '
                                        'must both be sent together; '
                                        f'`{not_empty_field_name}` cannot be '
                                        'sent individually.')
                })

        return attrs

    def validate_extra_details(self, value):
        desired_metadata_fields = json.loads(
            constance.config.USER_METADATA_FIELDS
        )
        errors = {}
        for field in desired_metadata_fields:
            if field['required'] and not value.get(field['name']):
                # Use verbatim message from DRF to avoid giving translators
                # more busy work
                errors[field['name']] = t('This field may not be blank.')
        if errors:
            raise serializers.ValidationError(errors)
        return value

    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()

        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)

        return super().update(
            instance, validated_data)