Commit bb2599ce by jsa Committed by Andy Armstrong

Include profile image version in URLs.

TNL-1860
parent 1d11345e
......@@ -8,15 +8,15 @@ from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'UserProfile.has_profile_image'
db.add_column('auth_userprofile', 'has_profile_image',
self.gf('django.db.models.fields.BooleanField')(default=False),
# Adding field 'UserProfile.profile_image_uploaded_at'
db.add_column('auth_userprofile', 'profile_image_uploaded_at',
self.gf('django.db.models.fields.DateTimeField')(null=True),
keep_default=False)
def backwards(self, orm):
# Deleting field 'UserProfile.has_profile_image'
db.delete_column('auth_userprofile', 'has_profile_image')
# Deleting field 'UserProfile.profile_image_uploaded_at'
db.delete_column('auth_userprofile', 'profile_image_uploaded_at')
models = {
......@@ -192,4 +192,4 @@ class Migration(SchemaMigration):
}
}
complete_apps = ['student']
\ No newline at end of file
complete_apps = ['student']
......@@ -173,7 +173,7 @@ class Migration(SchemaMigration):
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'has_profile_image': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'profile_image_uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
......
......@@ -251,7 +251,15 @@ class UserProfile(models.Model):
goals = models.TextField(blank=True, null=True)
allow_certificate = models.BooleanField(default=1)
bio = models.TextField(blank=True, null=True)
has_profile_image = models.BooleanField(default=0)
profile_image_uploaded_at = models.DateTimeField(null=True)
@property
def has_profile_image(self):
"""
Convenience method that returns a boolean indicating whether or not
this user has uploaded a profile image.
"""
return self.profile_image_uploaded_at is not None
def get_meta(self): # pylint: disable=missing-docstring
js_str = self.meta
......@@ -321,7 +329,7 @@ def user_profile_pre_save_callback(sender, **kwargs):
# Remove profile images for users who require parental consent
if user_profile.requires_parental_consent() and user_profile.has_profile_image:
user_profile.has_profile_image = False
user_profile.profile_image_uploaded_at = None
class UserSignupSource(models.Model):
......
......@@ -69,14 +69,14 @@ class ProfileParentalControlsTest(TestCase):
"""Verify that a profile's image obeys parental controls."""
# Verify that an image cannot be set for a user with no year of birth set
self.profile.has_profile_image = True
self.profile.profile_image_uploaded_at = datetime.datetime.now()
self.profile.save()
self.assertFalse(self.profile.has_profile_image)
# Verify that an image can be set for an adult user
current_year = datetime.datetime.now().year
self.set_year_of_birth(current_year - 20)
self.profile.has_profile_image = True
self.profile.profile_image_uploaded_at = datetime.datetime.now()
self.profile.save()
self.assertTrue(self.profile.has_profile_image)
......
......@@ -2,13 +2,14 @@
Test cases for the HTTP endpoints of the profile image api.
"""
from contextlib import closing
import datetime
from pytz import UTC
import unittest
from django.conf import settings
from django.core.urlresolvers import reverse
import mock
from mock import patch
from PIL import Image
from rest_framework.test import APITestCase, APIClient
......@@ -17,13 +18,14 @@ from student.tests.factories import UserFactory
from ...user_api.accounts.image_helpers import (
set_has_profile_image,
get_profile_image_names,
get_profile_image_storage
get_profile_image_storage,
)
from ..images import create_profile_images, ImageValidationError
from ..views import LOG_MESSAGE_CREATE, LOG_MESSAGE_DELETE
from .helpers import make_image_file
TEST_PASSWORD = "test"
TEST_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 01, tzinfo=UTC)
class ProfileImageEndpointTestCase(APITestCase):
......@@ -121,7 +123,8 @@ class ProfileImageUploadTestCase(ProfileImageEndpointTestCase):
self.assertEqual(401, response.status_code)
self.assertFalse(mock_log.info.called)
def test_upload_self(self, mock_log):
@patch('openedx.core.djangoapps.profile_images.views._make_upload_dt', return_value=TEST_UPLOAD_DT)
def test_upload_self(self, mock_make_image_version, mock_log): # pylint: disable=unused-argument
"""
Test that an authenticated user can POST to their own upload endpoint.
"""
......@@ -244,7 +247,7 @@ class ProfileImageRemoveTestCase(ProfileImageEndpointTestCase):
with make_image_file() as image_file:
create_profile_images(image_file, get_profile_image_names(self.user.username))
self.check_images()
set_has_profile_image(self.user.username, True)
set_has_profile_image(self.user.username, True, TEST_UPLOAD_DT)
def test_unsupported_methods(self, mock_log):
"""
......
......@@ -2,6 +2,7 @@
This module implements the upload and remove endpoints of the profile image api.
"""
from contextlib import closing
import datetime
import logging
from django.utils.translation import ugettext as _
......@@ -16,7 +17,7 @@ from openedx.core.lib.api.authentication import (
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 openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from .images import validate_uploaded_image, create_profile_images, remove_profile_images, ImageValidationError
log = logging.getLogger(__name__)
......@@ -25,6 +26,14 @@ LOG_MESSAGE_CREATE = 'Generated and uploaded images %(image_names)s for user %(u
LOG_MESSAGE_DELETE = 'Deleted images %(image_names)s for user %(user_id)s'
def _make_upload_dt():
"""
Generate a server-side timestamp for the upload. This is in a separate
function so its behavior can be overridden in tests.
"""
return datetime.datetime.utcnow()
class ProfileImageUploadView(APIView):
"""
**Use Cases**
......@@ -92,7 +101,7 @@ class ProfileImageUploadView(APIView):
create_profile_images(uploaded_file, profile_image_names)
# update the user account to reflect that a profile image is available.
set_has_profile_image(username, True)
set_has_profile_image(username, True, _make_upload_dt())
log.info(
LOG_MESSAGE_CREATE,
......
......@@ -47,15 +47,18 @@ def _get_profile_image_filename(name, size, file_extension=PROFILE_IMAGE_FILE_EX
return '{name}_{size}.{file_extension}'.format(name=name, size=size, file_extension=file_extension)
def _get_profile_image_urls(name, storage, file_extension=PROFILE_IMAGE_FILE_EXTENSION):
def _get_profile_image_urls(name, storage, file_extension=PROFILE_IMAGE_FILE_EXTENSION, version=None):
"""
Returns a dict containing the urls for a complete set of profile images,
keyed by "friendly" name (e.g. "full", "large", "medium", "small").
"""
return {
size_display_name: storage.url(_get_profile_image_filename(name, size, file_extension=file_extension))
for size_display_name, size in PROFILE_IMAGE_SIZES_MAP.items()
}
def _make_url(size): # pylint: disable=missing-docstring
url = storage.url(
_get_profile_image_filename(name, size, file_extension=file_extension)
)
return '{}?v={}'.format(url, version) if version is not None else url
return {size_display_name: _make_url(size) for size_display_name, size in PROFILE_IMAGE_SIZES_MAP.items()}
def get_profile_image_names(username):
......@@ -88,7 +91,11 @@ def get_profile_image_urls_for_user(user):
"""
if user.profile.has_profile_image:
return _get_profile_image_urls(_make_profile_image_name(user.username), get_profile_image_storage())
return _get_profile_image_urls(
_make_profile_image_name(user.username),
get_profile_image_storage(),
version=user.profile.profile_image_uploaded_at.strftime("%s"),
)
else:
return _get_default_profile_image_urls()
......@@ -103,19 +110,38 @@ def _get_default_profile_image_urls():
return _get_profile_image_urls(
settings.PROFILE_IMAGE_DEFAULT_FILENAME,
staticfiles_storage,
file_extension=settings.PROFILE_IMAGE_DEFAULT_FILE_EXTENSION
file_extension=settings.PROFILE_IMAGE_DEFAULT_FILE_EXTENSION,
)
def set_has_profile_image(username, has_profile_image=True):
def set_has_profile_image(username, is_uploaded, upload_dt=None):
"""
System (not user-facing) API call used to store whether the user has
uploaded a profile image. Used by profile_image API.
uploaded a profile image, and if so, when. Used by profile_image API.
Arguments:
username (django.contrib.auth.User.username): references the user who
uploaded an image.
is_uploaded (bool): whether or not the user has an uploaded profile
image.
upload_dt (datetime.datetime): If `is_uploaded` is True, this should
contain the server-side date+time of the upload. If `is_uploaded`
is False, the parameter is optional and will be ignored.
Raises:
ValueError: is_uploaded was True, but no upload datetime was supplied.
UserNotFound: no user with username `username` exists.
"""
if is_uploaded and upload_dt is None:
raise ValueError("No upload datetime was supplied.")
elif not is_uploaded:
upload_dt = None
try:
profile = UserProfile.objects.get(user__username=username)
except ObjectDoesNotExist:
raise UserNotFound()
profile.has_profile_image = has_profile_image
profile.profile_image_uploaded_at = upload_dt
profile.save()
"""
Tests for helpers.py
"""
import datetime
import hashlib
from mock import patch
from pytz import UTC
from unittest import skipUnless
from django.conf import settings
......@@ -12,6 +14,7 @@ from ..image_helpers import get_profile_image_urls_for_user
from student.tests.factories import UserFactory
TEST_SIZES = {'full': 50, 'small': 10}
TEST_PROFILE_IMAGE_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 01, tzinfo=UTC)
@patch.dict('openedx.core.djangoapps.user_api.accounts.image_helpers.PROFILE_IMAGE_SIZES_MAP', TEST_SIZES, clear=True)
......@@ -25,17 +28,17 @@ class ProfileImageUrlTestCase(TestCase):
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.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT
self.user.profile.save()
def verify_url(self, actual_url, expected_name, expected_pixels):
def verify_url(self, actual_url, expected_name, expected_pixels, expected_version):
"""
Verify correct url structure.
"""
self.assertEqual(
actual_url,
'http://example-storage.com/profile-images/{name}_{size}.jpg'.format(
name=expected_name, size=expected_pixels
'http://example-storage.com/profile-images/{name}_{size}.jpg?v={version}'.format(
name=expected_name, size=expected_pixels, version=expected_version
)
)
......@@ -48,7 +51,7 @@ class ProfileImageUrlTestCase(TestCase):
'/static/default_{size}.png'.format(size=expected_pixels)
)
def verify_urls(self, expected_name, actual_urls, is_default=False):
def verify_urls(self, actual_urls, expected_name, is_default=False):
"""
Verify correct url dictionary structure.
"""
......@@ -57,18 +60,20 @@ class ProfileImageUrlTestCase(TestCase):
if is_default:
self.verify_default_url(url, TEST_SIZES[size_display_name])
else:
self.verify_url(url, expected_name, TEST_SIZES[size_display_name])
self.verify_url(
url, expected_name, TEST_SIZES[size_display_name], TEST_PROFILE_IMAGE_UPLOAD_DT.strftime("%s")
)
def test_get_profile_image_urls(self):
"""
Tests `get_profile_image_urls_for_user`
"""
self.user.profile.has_profile_image = True
self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT
self.user.profile.save()
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.verify_urls(actual_urls, expected_name, is_default=False)
self.user.profile.has_profile_image = False
self.user.profile.profile_image_uploaded_at = None
self.user.profile.save()
self.verify_urls('default', get_profile_image_urls_for_user(self.user), is_default=True)
self.verify_urls(get_profile_image_urls_for_user(self.user), 'default', is_default=True)
......@@ -5,6 +5,7 @@ import ddt
import hashlib
import json
from mock import patch
from pytz import UTC
import unittest
from django.conf import settings
......@@ -19,6 +20,8 @@ from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KE
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from .. import PRIVATE_VISIBILITY, ALL_USERS_VISIBILITY
TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 01, tzinfo=UTC)
# this is used in one test to check the behavior of profile image url
# generation with a relative url in the config.
......@@ -97,7 +100,7 @@ class UserAPITestCase(APITestCase):
legacy_profile.mailing_address = "Park Ave"
legacy_profile.gender = "f"
legacy_profile.bio = "Tired mother of twins"
legacy_profile.has_profile_image = True
legacy_profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOADED_AT
legacy_profile.language_proficiencies.add(LanguageProficiency(code='en'))
legacy_profile.save()
......@@ -123,24 +126,23 @@ class TestAccountAPI(UserAPITestCase):
corresponds to whether the user has or hasn't set a profile
image.
"""
template = '{root}/{filename}_{{size}}.{extension}'
if has_profile_image:
url_root = 'http://example-storage.com/profile-images'
filename = hashlib.md5('secret' + self.user.username).hexdigest()
file_extension = 'jpg'
template += '?v={}'.format(TEST_PROFILE_IMAGE_UPLOADED_AT.strftime("%s"))
else:
url_root = 'http://testserver/static'
filename = 'default'
file_extension = 'png'
template = template.format(root=url_root, filename=filename, extension=file_extension)
self.assertEqual(
data['profile_image'],
{
'has_image': has_profile_image,
'image_url_full': '{root}/{filename}_50.{extension}'.format(
root=url_root, filename=filename, extension=file_extension,
),
'image_url_small': '{root}/{filename}_10.{extension}'.format(
root=url_root, filename=filename, extension=file_extension,
)
'image_url_full': template.format(size=50),
'image_url_small': template.format(size=10),
}
)
......
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