Commit 46b63164 by jsa Committed by Andy Armstrong

Implement profile_image upload and remove endpoints

TNL-1537

Co-Authored-By: Andy Armstrong <andya@edx.org>
Co-Authored-By: cahrens <christina@edx.org>
parent 97e44ed2
......@@ -18,7 +18,10 @@ from opaque_keys.edx.keys import CourseKey
from embargo import api as embargo_api
from cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from cors_csrf.decorators import ensure_csrf_cookie_cross_domain
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from util.disable_rate_limit import can_disable_rate_limit
from enrollment import api
from enrollment.errors import (
......
......@@ -15,7 +15,7 @@ from course_modes.models import CourseMode
from courseware import courses
from enrollment.api import add_enrollment
from student.models import CourseEnrollment
from util.authentication import SessionAuthenticationAllowInactiveUser
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
log = logging.getLogger(__name__)
......
......@@ -2,14 +2,16 @@
Common utility methods and decorators for Mobile APIs.
"""
import functools
from rest_framework import permissions
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from courseware.courses import get_course_with_access
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.permissions import IsUserInUrl
......
......@@ -2259,7 +2259,13 @@ FIELD_OVERRIDE_PROVIDERS = ()
# PROFILE IMAGE CONFIG
# TODO: add these settings to aws.py as well
PROFILE_IMAGE_BACKEND = 'django.core.files.storage.FileSystemStorage'
# WARNING: Certain django storage backends do not support atomic
# file overwrites (including the default, specified below) - instead
# there are separate calls to delete and then write a new file in the
# storage backend. This introduces the risk of a race condition
# occurring when a user uploads a new profile image to replace an
# earlier one (the file will temporarily be deleted).
PROFILE_IMAGE_BACKEND = 'storages.backends.overwrite.OverwriteStorage'
# PROFILE_IMAGE_DOMAIN points to the domain from which we serve image
# files from. When this is '/', it refers to the same domain as the
# app server. If serving from a different domain, specify that here
......@@ -2272,4 +2278,5 @@ PROFILE_IMAGE_DEFAULT_FILENAME = 'default_profile_image' # TODO: determine fina
# platform unaware of current image URLs, resulting in reverting all
# users' profile images to the default placeholder image.
PROFILE_IMAGE_SECRET_KEY = 'placeholder secret key'
PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
PROFILE_IMAGE_MIN_BYTES = 100
......@@ -483,3 +483,5 @@ PROFILE_IMAGE_DOMAIN = 'http://example-storage.com/'
PROFILE_IMAGE_URL_PATH = 'profile_images/'
PROFILE_IMAGE_DEFAULT_FILENAME = 'default'
PROFILE_IMAGE_SECRET_KEY = 'secret'
PROFILE_IMAGE_MAX_BYTES = 1024 * 1024
PROFILE_IMAGE_MIN_BYTES = 100
......@@ -91,6 +91,7 @@ urlpatterns = (
if settings.FEATURES["ENABLE_USER_REST_API"]:
urlpatterns += (
url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')),
url(r'^api/profile_images/', include('openedx.core.djangoapps.profile_images.urls')),
)
if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]:
......@@ -637,6 +638,9 @@ urlpatterns = patterns(*urlpatterns)
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(
settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH, document_root=settings.MEDIA_ROOT
)
# in debug mode, allow any template to be rendered (most useful for UX reference templates)
urlpatterns += url(r'^template/(?P<template>.+)$', 'debug.views.show_reference_template'),
......
"""
Image file manipulation functions related to profile images.
"""
from cStringIO import StringIO
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.translation import ugettext as _, ugettext_noop as _noop
from PIL import Image
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage
FILE_UPLOAD_TOO_LARGE = _noop('Maximum file size exceeded.')
FILE_UPLOAD_TOO_SMALL = _noop('Minimum file size not met.')
FILE_UPLOAD_BAD_TYPE = _noop('Unsupported file type.')
FILE_UPLOAD_BAD_EXT = _noop('File extension does not match data.')
FILE_UPLOAD_BAD_MIMETYPE = _noop('Content-Type header does not match data.')
class ImageValidationError(Exception):
"""
Exception to use when the system rejects a user-supplied source image.
"""
@property
def user_message(self):
"""
Translate the developer-facing exception message for API clients.
"""
# pylint: disable=translation-of-non-string
return _(self.message)
def validate_uploaded_image(uploaded_file):
"""
Raises ImageValidationError if the server should refuse to use this
uploaded file as the source image for a user's profile image. Otherwise,
returns nothing.
"""
# validation code by @pmitros,
# adapted from https://github.com/pmitros/ProfileXBlock
# see also: http://en.wikipedia.org/wiki/Magic_number_%28programming%29
image_types = {
'jpeg': {
'extension': [".jpeg", ".jpg"],
'mimetypes': ['image/jpeg', 'image/pjpeg'],
'magic': ["ffd8"]
},
'png': {
'extension': [".png"],
'mimetypes': ['image/png'],
'magic': ["89504e470d0a1a0a"]
},
'gif': {
'extension': [".gif"],
'mimetypes': ['image/gif'],
'magic': ["474946383961", "474946383761"]
}
}
if uploaded_file.size > settings.PROFILE_IMAGE_MAX_BYTES:
raise ImageValidationError(FILE_UPLOAD_TOO_LARGE)
elif uploaded_file.size < settings.PROFILE_IMAGE_MIN_BYTES:
raise ImageValidationError(FILE_UPLOAD_TOO_SMALL)
# check the file extension looks acceptable
filename = unicode(uploaded_file.name).lower()
filetype = [ft for ft in image_types if any(filename.endswith(ext) for ext in image_types[ft]['extension'])]
if not filetype:
raise ImageValidationError(FILE_UPLOAD_BAD_TYPE)
filetype = filetype[0]
# check mimetype matches expected file type
if uploaded_file.content_type not in image_types[filetype]['mimetypes']:
raise ImageValidationError(FILE_UPLOAD_BAD_MIMETYPE)
# check magic number matches expected file type
headers = image_types[filetype]['magic']
if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers:
raise ImageValidationError(FILE_UPLOAD_BAD_EXT)
# avoid unexpected errors from subsequent modules expecting the fp to be at 0
uploaded_file.seek(0)
def _get_scaled_image_file(image_obj, size):
"""
Given a PIL.Image object, get a resized copy using `size` (square) and
return a file-like object containing the data saved as a JPEG.
Note that the file object returned is a django ContentFile which holds
data in memory (not on disk).
"""
if image_obj.mode != "RGB":
image_obj = image_obj.convert("RGB")
scaled = image_obj.resize((size, size), Image.ANTIALIAS)
string_io = StringIO()
scaled.save(string_io, format='JPEG')
image_file = ContentFile(string_io.getvalue())
return image_file
def create_profile_images(image_file, profile_image_names):
"""
Generates a set of image files based on image_file and
stores them according to the sizes and filenames specified
in `profile_image_names`.
"""
image_obj = Image.open(image_file)
# first center-crop the image if needed (but no scaling yet).
width, height = image_obj.size
if width != height:
side = width if width < height else height
image_obj = image_obj.crop(((width - side) / 2, (height - side) / 2, (width + side) / 2, (height + side) / 2))
storage = get_profile_image_storage()
for size, name in profile_image_names.items():
scaled_image_file = _get_scaled_image_file(image_obj, size)
# Store the file.
try:
storage.save(name, scaled_image_file)
finally:
scaled_image_file.close()
def remove_profile_images(profile_image_names):
"""
Physically remove the image files specified in `profile_image_names`
"""
storage = get_profile_image_storage()
for name in profile_image_names.values():
storage.delete(name)
"""
Helper methods for use in profile image tests.
"""
from contextlib import contextmanager
import os
from tempfile import NamedTemporaryFile
from django.core.files.uploadedfile import UploadedFile
from PIL import Image
@contextmanager
def make_image_file(dimensions=(320, 240), extension=".jpeg", force_size=None):
"""
Yields a named temporary file created with the specified image type and
options.
Note the default dimensions are unequal (not a square) ensuring that center-square
cropping logic will be exercised during tests.
The temporary file will be closed and deleted automatically upon exiting
the `with` block.
"""
image = Image.new('RGB', dimensions, "green")
image_file = NamedTemporaryFile(suffix=extension)
try:
image.save(image_file)
if force_size is not None:
image_file.seek(0, os.SEEK_END)
bytes_to_pad = force_size - image_file.tell()
# write in hunks of 256 bytes
hunk, byte_ = bytearray([0] * 256), bytearray([0])
num_hunks, remainder = divmod(bytes_to_pad, 256)
for _ in xrange(num_hunks):
image_file.write(hunk)
for _ in xrange(remainder):
image_file.write(byte_)
image_file.flush()
image_file.seek(0)
yield image_file
finally:
image_file.close()
@contextmanager
def make_uploaded_file(content_type, *a, **kw):
"""
Wrap the result of make_image_file in a django UploadedFile.
"""
with make_image_file(*a, **kw) as image_file:
yield UploadedFile(
image_file,
content_type=content_type,
size=os.path.getsize(image_file.name),
)
"""
Test cases for image processing functions in the profile image package.
"""
from contextlib import closing
from itertools import product
import os
from tempfile import NamedTemporaryFile
import unittest
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.test import TestCase
from django.test.utils import override_settings
import ddt
import mock
from PIL import Image
from ..images import (
FILE_UPLOAD_TOO_LARGE,
FILE_UPLOAD_TOO_SMALL,
FILE_UPLOAD_BAD_TYPE,
FILE_UPLOAD_BAD_EXT,
FILE_UPLOAD_BAD_MIMETYPE,
create_profile_images,
ImageValidationError,
remove_profile_images,
validate_uploaded_image,
)
from .helpers import make_image_file, make_uploaded_file
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
class TestValidateUploadedImage(TestCase):
"""
Test validate_uploaded_image
"""
def check_validation_result(self, uploaded_file, expected_failure_message):
"""
Internal DRY helper.
"""
if expected_failure_message is not None:
with self.assertRaises(ImageValidationError) as ctx:
validate_uploaded_image(uploaded_file)
self.assertEqual(ctx.exception.message, expected_failure_message)
else:
validate_uploaded_image(uploaded_file)
self.assertEqual(uploaded_file.tell(), 0)
@ddt.data(
(99, FILE_UPLOAD_TOO_SMALL),
(100, ),
(1024, ),
(1025, FILE_UPLOAD_TOO_LARGE),
)
@ddt.unpack
@override_settings(PROFILE_IMAGE_MIN_BYTES=100, PROFILE_IMAGE_MAX_BYTES=1024)
def test_file_size(self, upload_size, expected_failure_message=None):
"""
Ensure that files outside the accepted size range fail validation.
"""
with make_uploaded_file(
dimensions=(1, 1), extension=".png", content_type="image/png", force_size=upload_size
) as uploaded_file:
self.check_validation_result(uploaded_file, expected_failure_message)
@ddt.data(
(".gif", "image/gif"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".png", "image/png"),
(".bmp", "image/bmp", FILE_UPLOAD_BAD_TYPE),
(".tif", "image/tiff", FILE_UPLOAD_BAD_TYPE),
)
@ddt.unpack
def test_extension(self, extension, content_type, expected_failure_message=None):
"""
Ensure that files whose extension is not supported fail validation.
"""
with make_uploaded_file(extension=extension, content_type=content_type) as uploaded_file:
self.check_validation_result(uploaded_file, expected_failure_message)
def test_extension_mismatch(self):
"""
Ensure that validation fails when the file extension does not match the
file data.
"""
# make a bmp, try to fool the function into thinking it's a jpeg
with make_image_file(extension=".bmp") as bmp_file:
with closing(NamedTemporaryFile(suffix=".jpeg")) as fake_jpeg_file:
fake_jpeg_file.write(bmp_file.read())
fake_jpeg_file.seek(0)
uploaded_file = UploadedFile(
fake_jpeg_file,
content_type="image/jpeg",
size=os.path.getsize(fake_jpeg_file.name)
)
with self.assertRaises(ImageValidationError) as ctx:
validate_uploaded_image(uploaded_file)
self.assertEqual(ctx.exception.message, FILE_UPLOAD_BAD_EXT)
def test_content_type(self):
"""
Ensure that validation fails when the content_type header and file
extension do not match
"""
with make_uploaded_file(extension=".jpeg", content_type="image/gif") as uploaded_file:
with self.assertRaises(ImageValidationError) as ctx:
validate_uploaded_image(uploaded_file)
self.assertEqual(ctx.exception.message, FILE_UPLOAD_BAD_MIMETYPE)
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
class TestGenerateProfileImages(TestCase):
"""
Test create_profile_images
"""
@ddt.data(
*product(
["gif", "jpg", "png"],
[(1, 1), (10, 10), (100, 100), (1000, 1000), (1, 10), (10, 100), (100, 1000), (1000, 999)],
)
)
@ddt.unpack
def test_generation(self, image_type, dimensions):
"""
Ensure that regardless of the input format or dimensions, the outcome
of calling the function is square jpeg files with explicitly-requested
dimensions being saved to the profile image storage backend.
"""
extension = "." + image_type
content_type = "image/" + image_type
requested_sizes = {
10: "ten.jpg",
100: "hundred.jpg",
1000: "thousand.jpg",
}
mock_storage = mock.Mock()
with make_uploaded_file(dimensions=dimensions, extension=extension, content_type=content_type) as uploaded_file:
with mock.patch(
"openedx.core.djangoapps.profile_images.images.get_profile_image_storage",
return_value=mock_storage,
):
create_profile_images(uploaded_file, requested_sizes)
names_and_files = [v[0] for v in mock_storage.save.call_args_list]
actual_sizes = {}
for name, file_ in names_and_files:
# get the size of the image file and ensure it's square jpeg
with closing(Image.open(file_)) as image_obj:
width, height = image_obj.size
self.assertEqual(width, height)
self.assertEqual(image_obj.format, 'JPEG')
actual_sizes[width] = name
self.assertEqual(requested_sizes, actual_sizes)
mock_storage.save.reset_mock()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Profile Image API is only supported in LMS')
class TestRemoveProfileImages(TestCase):
"""
Test remove_profile_images
"""
def test_remove(self):
"""
Ensure that the outcome of calling the function is that the named images
are deleted from the profile image storage backend.
"""
requested_sizes = {
10: "ten.jpg",
100: "hundred.jpg",
1000: "thousand.jpg",
}
mock_storage = mock.Mock()
with mock.patch(
"openedx.core.djangoapps.profile_images.images.get_profile_image_storage",
return_value=mock_storage,
):
remove_profile_images(requested_sizes)
deleted_names = [v[0][0] for v in mock_storage.delete.call_args_list]
self.assertEqual(requested_sizes.values(), deleted_names)
mock_storage.save.reset_mock()
"""
Defines the URL routes for this app.
"""
from .views import ProfileImageUploadView, ProfileImageRemoveView
from django.conf.urls import patterns, url
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'',
url(
r'^v0/' + USERNAME_PATTERN + '/upload$',
ProfileImageUploadView.as_view(),
name="profile_image_upload"
),
url(
r'^v0/' + USERNAME_PATTERN + '/remove$',
ProfileImageRemoveView.as_view(),
name="profile_image_remove"
),
)
"""
This module implements the upload and remove endpoints of the profile image api.
"""
from contextlib import closing
from django.utils.translation import ugettext as _
from rest_framework import permissions, status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.views import APIView
from openedx.core.djangoapps.user_api.errors import UserNotFound
from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.permissions import IsUserInUrl, IsUserInUrlOrStaff
from openedx.core.djangoapps.user_api.accounts.image_helpers import set_has_profile_image, get_profile_image_names
from .images import validate_uploaded_image, create_profile_images, remove_profile_images, ImageValidationError
class ProfileImageUploadView(APIView):
"""
**Use Cases**
Uploads an image to be used for the user's profile.
**Example Requests**:
POST /api/profile_images/v0/{username}/upload
**Response for POST**
Users can only upload their own profile image. If the requesting user does not have username
"username", this method will return with a status of 403 for staff access but a 404 for ordinary
users to avoid leaking the existence of the account.
This method will also return a 404 if no user exists with username "username".
If the upload could not be performed then this method returns a 400 with specific errors
in the returned JSON.
If the update is successful, a 204 status is returned with no additional content.
"""
parser_classes = (MultiPartParser, FormParser,)
authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
permission_classes = (permissions.IsAuthenticated, IsUserInUrl)
def post(self, request, username):
"""
POST /api/profile_images/v0/{username}/upload
"""
# validate request:
# verify that the user's
# ensure any file was sent
if 'file' not in request.FILES:
return Response(
{
"developer_message": u"No file provided for profile image",
"user_message": _(u"No file provided for profile image"),
},
status=status.HTTP_400_BAD_REQUEST
)
try:
# process the upload.
uploaded_file = request.FILES['file']
# no matter what happens, delete the temporary file when we're done
with closing(uploaded_file):
# image file validation.
try:
validate_uploaded_image(uploaded_file)
except ImageValidationError as error:
return Response(
{"developer_message": error.message, "user_message": error.user_message},
status=status.HTTP_400_BAD_REQUEST,
)
# generate profile pic and thumbnails and store them
create_profile_images(uploaded_file, get_profile_image_names(username))
# update the user account to reflect that a profile image is available.
set_has_profile_image(username, True)
except Exception as error:
return Response(
{
"developer_message": u"Upload failed for profile image: {error}".format(error=error),
"user_message": _(u"Upload failed for profile image"),
},
status=status.HTTP_400_BAD_REQUEST
)
# send client response.
return Response(status=status.HTTP_204_NO_CONTENT)
class ProfileImageRemoveView(APIView):
"""
**Use Cases**
Removes all of the profile images associated with the user's account.
**Example Requests**:
POST /api/profile_images/v0/{username}/remove
**Response for POST**
Users are authorized to delete their own profile images, while staff can delete images for
any account. All other users will receive a 404 to avoid leaking the existence of the account.
This method will also return a 404 if no user exists with username "username".
If the delete could not be performed then this method returns a 400 with specific errors
in the returned JSON.
If the delete is successful, a 204 status is returned with no additional content.
"""
authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
def post(self, request, username): # pylint: disable=unused-argument
"""
POST /api/profile_images/v0/{username}/remove
"""
try:
# update the user account to reflect that the images were removed.
set_has_profile_image(username, False)
# remove physical files from storage.
remove_profile_images(get_profile_image_names(username))
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
except Exception as error:
return Response(
{
"developer_message": u"Delete failed for profile image: {error}".format(error=error),
"user_message": _(u"Delete failed for profile image"),
},
status=status.HTTP_400_BAD_REQUEST
)
# send client response.
return Response(status=status.HTTP_204_NO_CONTENT)
"""
Helper functions for the accounts API.
"""
import hashlib
from django.conf import settings
from django.core.files.storage import FileSystemStorage, get_storage_class
PROFILE_IMAGE_SIZES_MAP = {
'full': 500,
'large': 120,
'medium': 50,
'small': 30
}
_PROFILE_IMAGE_SIZES = PROFILE_IMAGE_SIZES_MAP.values()
PROFILE_IMAGE_FORMAT = 'jpg'
def get_profile_image_url_for_user(user, size):
"""Return the URL to a user's profile image for a given size.
Note that based on the value of
django.conf.settings.PROFILE_IMAGE_DOMAIN, the URL may be relative,
and in that case the caller is responsible for constructing the full
URL.
If the user has not yet uploaded a profile image, return the URL to
the default edX user profile image.
Arguments:
user (django.auth.User): The user for whom we're generating a
profile image URL.
Returns:
string: The URL for the user's profile image.
Raises:
ValueError: The caller asked for an unsupported image size.
"""
if size not in _PROFILE_IMAGE_SIZES:
raise ValueError('Unsupported profile image size: {size}'.format(size=size))
if user.profile.has_profile_image:
name = hashlib.md5(settings.PROFILE_IMAGE_SECRET_KEY + user.username).hexdigest()
else:
name = settings.PROFILE_IMAGE_DEFAULT_FILENAME
filename = '{name}_{size}.{format}'.format(name=name, size=size, format=PROFILE_IMAGE_FORMAT)
# Note that, for now, the backend will be FileSystemStorage. When
# we eventually support s3 storage, we'll need to pass a parameter
# to the storage class indicating the s3 bucket which we're using
# for profile picture uploads.
storage_class = get_storage_class(settings.PROFILE_IMAGE_BACKEND)
if storage_class == FileSystemStorage:
kwargs = {'base_url': (settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH)}
storage = storage_class(**kwargs)
return storage.url(filename)
"""
Helper functions for the accounts API.
"""
import hashlib
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.storage import get_storage_class
from student.models import UserProfile
from ..errors import UserNotFound
PROFILE_IMAGE_SIZES_MAP = {
'full': 500,
'large': 120,
'medium': 50,
'small': 30
}
_PROFILE_IMAGE_SIZES = PROFILE_IMAGE_SIZES_MAP.values()
def get_profile_image_storage():
"""
Configures and returns a django Storage instance that can be used
to physically locate, read and write profile images.
"""
storage_class = get_storage_class(settings.PROFILE_IMAGE_BACKEND)
return storage_class(base_url=(settings.PROFILE_IMAGE_DOMAIN + settings.PROFILE_IMAGE_URL_PATH))
def _make_profile_image_name(username):
"""
Returns the user-specific part of the image filename, based on a hash of
the username.
"""
return hashlib.md5(settings.PROFILE_IMAGE_SECRET_KEY + username).hexdigest()
def _get_profile_image_filename(name, size):
"""
Returns the full filename for a profile image, given the name and size.
"""
return '{name}_{size}.jpg'.format(name=name, size=size)
def _get_profile_image_urls(name):
"""
Returns a dict containing the urls for a complete set of profile images,
keyed by "friendly" name (e.g. "full", "large", "medium", "small").
"""
storage = get_profile_image_storage()
return {
size_display_name: storage.url(_get_profile_image_filename(name, size))
for size_display_name, size in PROFILE_IMAGE_SIZES_MAP.items()
}
def get_profile_image_names(username):
"""
Returns a dict containing the filenames for a complete set of profile
images, keyed by pixel size.
"""
name = _make_profile_image_name(username)
return {size: _get_profile_image_filename(name, size) for size in _PROFILE_IMAGE_SIZES}
def get_profile_image_urls_for_user(user):
"""
Return a dict {size:url} for each profile image for a given user.
Notes:
- this function does not determine whether the set of profile images
exists, only what the URLs will be if they do exist. It is assumed that
callers will use `_get_default_profile_image_urls` instead to provide
a set of urls that point to placeholder images, when there are no user-
submitted images.
- based on the value of django.conf.settings.PROFILE_IMAGE_DOMAIN,
the URL may be relative, and in that case the caller is responsible for
constructing the full URL if needed.
Arguments:
user (django.contrib.auth.User): the user for whom we are getting urls.
Returns:
dictionary of {size_display_name: url} for each image.
"""
if user.profile.has_profile_image:
return _get_profile_image_urls(_make_profile_image_name(user.username))
else:
return _get_default_profile_image_urls()
def _get_default_profile_image_urls():
"""
Returns a dict {size:url} for a complete set of default profile images,
used as a placeholder when there are no user-submitted images.
TODO The result of this function should be memoized, but not in tests.
"""
return _get_profile_image_urls(settings.PROFILE_IMAGE_DEFAULT_FILENAME)
def set_has_profile_image(username, has_profile_image=True):
"""
System (not user-facing) API call used to store whether the user has
uploaded a profile image. Used by profile_image API.
"""
try:
profile = UserProfile.objects.get(user__username=username)
except ObjectDoesNotExist:
raise UserNotFound()
profile.has_profile_image = has_profile_image
profile.save()
......@@ -4,7 +4,8 @@ from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency
from .helpers import get_profile_image_url_for_user, PROFILE_IMAGE_SIZES_MAP
from .image_helpers import get_profile_image_urls_for_user
PROFILE_IMAGE_KEY_PREFIX = 'image_url'
......@@ -100,10 +101,10 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
def get_profile_image(self, user_profile):
""" Returns metadata about a user's profile image. """
data = {'has_image': user_profile.has_profile_image}
urls = get_profile_image_urls_for_user(user_profile.user)
data.update({
'{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name):
get_profile_image_url_for_user(user_profile.user, size_value)
for size_display_name, size_value in PROFILE_IMAGE_SIZES_MAP.items()
'{image_key_prefix}_{size}'.format(image_key_prefix=PROFILE_IMAGE_KEY_PREFIX, size=size_display_name): url
for size_display_name, url in urls.items()
})
return data
......
......@@ -192,12 +192,13 @@ class TestAccountApi(TestCase):
self.assertEqual(0, len(pending_change))
@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
@patch('openedx.core.djangoapps.user_api.accounts.image_helpers._PROFILE_IMAGE_SIZES', [50, 10])
@patch.dict(
'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
'openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class AccountSettingsOnCreationTest(TestCase):
# pylint: disable=missing-docstring
USERNAME = u'frank-underwood'
PASSWORD = u'ṕáśśẃőŕd'
......
"""
Tests for helpers.py
"""
from ddt import ddt, data
import hashlib
from mock import patch
from unittest import skipUnless
......@@ -9,61 +8,53 @@ from unittest import skipUnless
from django.conf import settings
from django.test import TestCase
from ..image_helpers import get_profile_image_urls_for_user
from student.tests.factories import UserFactory
from ...models import UserProfile
from ..helpers import get_profile_image_url_for_user
TEST_SIZES = {'full': 50, 'small': 10}
@ddt
@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
@patch.dict(
'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
)
@patch.dict('openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', TEST_SIZES, clear=True)
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ProfileImageUrlTestCase(TestCase):
"""
Tests for `get_profile_image_url_for_user`.
Tests for profile image URL generation helpers.
"""
def setUp(self):
super(ProfileImageUrlTestCase, self).setUp()
self.user = UserFactory()
# Ensure that parental controls don't apply to this user
self.user.profile.year_of_birth = 1980
self.user.profile.has_profile_image = False
self.user.profile.save()
def verify_url(self, user, pixels, filename):
def verify_url(self, actual_url, expected_name, expected_pixels):
"""
Helper method to verify that we're correctly generating profile
image URLs.
Verify correct url structure.
"""
self.assertEqual(
get_profile_image_url_for_user(user, pixels),
'http://example-storage.com/profile_images/{filename}_{pixels}.jpg'.format(filename=filename, pixels=pixels)
actual_url,
'http://example-storage.com/profile_images/{0}_{1}.jpg'.format(expected_name, expected_pixels),
)
@data(10, 50)
def test_profile_image_urls(self, pixels):
def verify_urls(self, expected_name, actual_urls):
"""
Verify correct url dictionary structure.
"""
self.assertEqual(set(TEST_SIZES.keys()), set(actual_urls.keys()))
for size_display_name, url in actual_urls.items():
self.verify_url(url, expected_name, TEST_SIZES[size_display_name])
def test_get_profile_image_urls(self):
"""
Verify we get the URL to the default image if the user does not
have a profile image.
Tests `get_profile_image_urls_for_user`
"""
# By default new users will have no profile image.
self.verify_url(self.user, pixels, 'default')
# A user can add an image, then remove it. We should get the
# default image URL in that case.
self.user.profile.has_profile_image = True
self.user.profile.save()
self.verify_url(self.user, pixels, hashlib.md5('secret' + self.user.username).hexdigest())
expected_name = hashlib.md5('secret' + self.user.username).hexdigest()
actual_urls = get_profile_image_urls_for_user(self.user)
self.verify_urls(expected_name, actual_urls)
self.user.profile.has_profile_image = False
self.user.profile.save()
self.verify_url(self.user, pixels, 'default')
@data(1, 5000)
def test_unsupported_sizes(self, image_size):
"""
Verify that we cannot ask for image sizes which are unsupported.
"""
with self.assertRaises(ValueError):
get_profile_image_url_for_user(self.user, image_size)
self.verify_urls('default', get_profile_image_urls_for_user(self.user))
......@@ -97,9 +97,9 @@ class UserAPITestCase(APITestCase):
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
@patch('openedx.core.djangoapps.user_api.accounts.helpers._PROFILE_IMAGE_SIZES', [50, 10])
@patch('openedx.core.djangoapps.user_api.accounts.image_helpers._PROFILE_IMAGE_SIZES', [50, 10])
@patch.dict(
'openedx.core.djangoapps.user_api.accounts.helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
'openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', {'full': 50, 'small': 10}, clear=True
)
class TestAccountAPI(UserAPITestCase):
"""
......@@ -200,7 +200,7 @@ class TestAccountAPI(UserAPITestCase):
"""
client = self.login_client(api_client, user)
response = client.get(reverse("accounts_api", kwargs={'username': "does_not_exist"}))
self.assertEqual(404, response.status_code)
self.assertEqual(403 if user == "staff_user" else 404, response.status_code)
# Note: using getattr so that the patching works even if there is no configuration.
# This is needed when testing CMS as the patching is still executed even though the
......@@ -279,7 +279,6 @@ class TestAccountAPI(UserAPITestCase):
for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"):
self.assertIsNone(data[empty_field])
self.assertIsNone(data["country"])
# TODO: what should the format of this be?
self.assertEqual("m", data["gender"])
self.assertEqual("Learn a lot", data["goals"])
self.assertEqual(self.user.email, data["email"])
......@@ -323,7 +322,7 @@ class TestAccountAPI(UserAPITestCase):
is_staff access).
"""
client = self.login_client(api_client, user)
self.send_patch(client, {}, expected_status=404)
self.send_patch(client, {}, expected_status=403 if user == "staff_user" else 404)
@ddt.data(
("client", "user"),
......
......@@ -8,9 +8,12 @@ from django.db import transaction
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from rest_framework import permissions
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
from openedx.core.lib.api.parsers import MergePatchParser
from .api import get_account_settings, update_account_settings
......@@ -123,8 +126,10 @@ class AccountView(APIView):
**Response Values for PATCH**
Users can modify only their own account information. If the user
attempts to modify another user's account, a 404 error is returned.
Users can only modify their own account information. If the requesting
user does not have username "username", this method will return with
a status of 403 for staff access but a 404 for ordinary users
to avoid leaking the existence of the account.
If no user exists with the specified username, a 404 error is
returned.
......@@ -158,7 +163,7 @@ class AccountView(APIView):
if key.startswith(PROFILE_IMAGE_KEY_PREFIX):
account_settings['profile_image'][key] = request.build_absolute_uri(value)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND)
return Response(account_settings)
......@@ -173,7 +178,9 @@ class AccountView(APIView):
try:
with transaction.commit_on_success():
update_account_settings(request.user, request.DATA, username=username)
except (UserNotFound, UserNotAuthorized):
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN if request.user.is_staff else status.HTTP_404_NOT_FOUND)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
except AccountValidationError as err:
return Response({"field_errors": err.field_errors}, status=status.HTTP_400_BAD_REQUEST)
......
......@@ -256,7 +256,6 @@ def update_email_opt_in(user, org, opt_in):
log.warn(u"Could not update organization wide preference due to IntegrityError: {}".format(err.message))
def _track_update_email_opt_in(user_id, organization, opt_in):
"""Track an email opt-in preference change.
......
......@@ -160,7 +160,7 @@ class TestPreferencesAPI(UserAPITestCase):
"dict_pref": {"int_key": 10},
"string_pref": "value",
},
expected_status=404,
expected_status=403 if user == "staff_user" else 404,
)
def test_update_preferences(self):
......@@ -288,7 +288,7 @@ class TestPreferencesAPI(UserAPITestCase):
"new_pref": "new_value",
"extra_pref": None,
},
expected_status=404
expected_status=403 if user == "staff_user" else 404
)
......@@ -503,7 +503,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
self._set_url("new_key")
client = self.login_client(api_client, user)
new_value = "new value"
self.send_put(client, new_value, expected_status=404)
self.send_put(client, new_value, expected_status=403 if user == "staff_user" else 404)
@ddt.data(
(u"new value",),
......@@ -531,7 +531,7 @@ class TestPreferencesDetailAPI(UserAPITestCase):
"""
client = self.login_client(api_client, user)
new_value = "new value"
self.send_put(client, new_value, expected_status=404)
self.send_put(client, new_value, expected_status=403 if user == "staff_user" else 404)
@ddt.data(
(None,),
......@@ -578,4 +578,4 @@ class TestPreferencesDetailAPI(UserAPITestCase):
Test that a client (logged in) cannot delete a preference for another user.
"""
client = self.login_client(api_client, user)
self.send_delete(client, expected_status=404)
self.send_delete(client, expected_status=403 if user == "staff_user" else 404)
......@@ -7,13 +7,17 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from util.authentication import SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser
from rest_framework import permissions
from django.db import transaction
from django.utils.translation import ugettext as _
from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.parsers import MergePatchParser
from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
from ..errors import UserNotFound, UserNotAuthorized, PreferenceValidationError, PreferenceUpdateError
from .api import (
get_user_preference, get_user_preferences, set_user_preference, update_user_preferences, delete_user_preference
......@@ -45,7 +49,8 @@ class PreferencesView(APIView):
**Response for PATCH**
Users can only modify their own preferences. If the requesting user does not have username
"username", this method will return with a status of 404.
"username", this method will return with a status of 403 for staff access but a 404 for ordinary
users to avoid leaking the existence of the account.
This method will also return a 404 if no user exists with username "username".
......@@ -61,7 +66,7 @@ class PreferencesView(APIView):
"""
authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
permission_classes = (permissions.IsAuthenticated,)
permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
parser_classes = (MergePatchParser,)
def get(self, request, username):
......@@ -70,7 +75,9 @@ class PreferencesView(APIView):
"""
try:
user_preferences = get_user_preferences(request.user, username=username)
except (UserNotFound, UserNotAuthorized):
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(user_preferences)
......@@ -91,7 +98,9 @@ class PreferencesView(APIView):
try:
with transaction.commit_on_success():
update_user_preferences(request.user, request.DATA, username=username)
except (UserNotFound, UserNotAuthorized):
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
except PreferenceValidationError as error:
return Response(
......@@ -136,17 +145,25 @@ class PreferencesDetailView(APIView):
A successful put returns a 204 and no content.
If the specified username or preference does not exist, this method returns a 404.
Users can only update their own preferences. If the requesting user does not have username
"username", this method will return with a status of 403 for staff access but a 404 for ordinary
users to avoid leaking the existence of the account.
If the specified preference does not exist, this method returns a 404.
**Response for DELETE**
A successful delete returns a 204 and no content.
If the specified username or preference does not exist, this method returns a 404.
Users can only delete their own preferences. If the requesting user does not have username
"username", this method will return with a status of 403 for staff access but a 404 for ordinary
users to avoid leaking the existence of the account.
If the specified preference does not exist, this method returns a 404.
"""
authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser)
permission_classes = (permissions.IsAuthenticated,)
permission_classes = (permissions.IsAuthenticated, IsUserInUrlOrStaff)
def get(self, request, username, preference_key):
"""
......@@ -157,7 +174,9 @@ class PreferencesDetailView(APIView):
# There was no preference with that key, raise a 404.
if value is None:
return Response(status=status.HTTP_404_NOT_FOUND)
except (UserNotFound, UserNotAuthorized):
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(value)
......@@ -167,7 +186,9 @@ class PreferencesDetailView(APIView):
"""
try:
set_user_preference(request.user, preference_key, request.DATA, username=username)
except (UserNotFound, UserNotAuthorized):
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
except PreferenceValidationError as error:
return Response(
......@@ -193,7 +214,9 @@ class PreferencesDetailView(APIView):
"""
try:
preference_existed = delete_user_preference(request.user, preference_key, username=username)
except (UserNotFound, UserNotAuthorized):
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
except PreferenceUpdateError as error:
return Response(
......
......@@ -1693,6 +1693,7 @@ class TestGoogleRegistrationView(
"""Tests the User API registration endpoint with Google authentication."""
pass
@ddt.ddt
class UpdateEmailOptInTestCase(ApiTestCase, ModuleStoreTestCase):
"""Tests the UpdateEmailOptInPreference view. """
......
"""HTTP end-points for the User API. """
import copy
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
from opaque_keys import InvalidKeyError
import third_party_auth
from django.conf import settings
from django.contrib.auth.models import User
from django.http import HttpResponse
......@@ -21,12 +18,14 @@ from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.exceptions import ParseError
from django_countries import countries
from django_comment_common.models import Role
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from edxmako.shortcuts import marketing_link
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
import third_party_auth
from django_comment_common.models import Role
from edxmako.shortcuts import marketing_link
from student.views import create_account_with_params, set_marketing_cookie
from util.authentication import SessionAuthenticationAllowInactiveUser
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
from util.json_request import JsonResponse
from .preferences.api import update_email_opt_in
from .helpers import FormDescription, shim_student_view, require_post_params
......
......@@ -52,9 +52,26 @@ class IsUserInUrl(permissions.BasePermission):
Permission that checks to see if the request user matches the user in the URL.
"""
def has_permission(self, request, view):
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an account.
"""
Returns true if the current request is by the user themselves.
Note: a 404 is returned for non-staff instead of a 403. This is to prevent
users from being able to detect the existence of accounts.
"""
url_username = request.parser_context.get('kwargs', {}).get('username', '')
if request.user.username.lower() != url_username.lower():
if request.user.is_staff:
return False # staff gets 403
raise Http404()
return True
class IsUserInUrlOrStaff(IsUserInUrl):
"""
Permission that checks to see if the request user matches the user in the URL or has is_staff access.
"""
def has_permission(self, request, view):
if request.user.is_staff:
return True
return super(IsUserInUrlOrStaff, self).has_permission(request, view)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment