diff --git a/common/djangoapps/student/cookies.py b/common/djangoapps/student/cookies.py index b508f93339..454ed4e486 100644 --- a/common/djangoapps/student/cookies.py +++ b/common/djangoapps/student/cookies.py @@ -1,17 +1,42 @@ """ Utility functions for setting "logged in" cookies used by subdomains. """ +from __future__ import unicode_literals -import time import json +import time -from django.dispatch import Signal - -from django.utils.http import cookie_date +import six from django.conf import settings from django.core.urlresolvers import reverse, NoReverseMatch +from django.dispatch import Signal +from django.utils.http import cookie_date -CREATE_LOGON_COOKIE = Signal(providing_args=["user", "response"]) +from student.models import CourseEnrollment + +CREATE_LOGON_COOKIE = Signal(providing_args=['user', 'response']) + + +def _get_cookie_settings(request): + """ Returns the common cookie settings (e.g. expiration time). """ + + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = cookie_date(expires_time) + + cookie_settings = { + 'max_age': max_age, + 'expires': expires, + 'domain': settings.SESSION_COOKIE_DOMAIN, + 'path': '/', + 'httponly': None, + } + + return cookie_settings def set_logged_in_cookies(request, response, user): @@ -31,7 +56,6 @@ def set_logged_in_cookies(request, response, user): { "version": 1, "username": "test-user", - "email": "test-user@example.com", "header_urls": { "account_settings": "https://example.com/account/settings", "learner_profile": "https://example.com/u/test-user", @@ -49,21 +73,7 @@ def set_logged_in_cookies(request, response, user): HttpResponse """ - if request.session.get_expire_at_browser_close(): - max_age = None - expires = None - else: - max_age = request.session.get_expiry_age() - expires_time = time.time() + max_age - expires = cookie_date(expires_time) - - cookie_settings = { - 'max_age': max_age, - 'expires': expires, - 'domain': settings.SESSION_COOKIE_DOMAIN, - 'path': '/', - 'httponly': None, - } + cookie_settings = _get_cookie_settings(request) # Backwards compatibility: set the cookie indicating that the user # is logged in. This is just a boolean value, so it's not very useful. @@ -76,6 +86,42 @@ def set_logged_in_cookies(request, response, user): **cookie_settings ) + set_user_info_cookie(response, request) + + # give signal receivers a chance to add cookies + CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response) + + return response + + +def set_user_info_cookie(response, request): + """ Sets the user info cookie on the response. """ + cookie_settings = _get_cookie_settings(request) + + # In production, TLS should be enabled so that this cookie is encrypted + # when we send it. We also need to set "secure" to True so that the browser + # will transmit it only over secure connections. + # + # In non-production environments (acceptance tests, devstack, and sandboxes), + # we still want to set this cookie. However, we do NOT want to set it to "secure" + # because the browser won't send it back to us. This can cause an infinite redirect + # loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine + # whether it needs to set the cookie or continue to the next pipeline stage. + user_info_cookie_is_secure = request.is_secure() + user_info = get_user_info_cookie_data(request) + + response.set_cookie( + settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'), + json.dumps(user_info), + secure=user_info_cookie_is_secure, + **cookie_settings + ) + + +def get_user_info_cookie_data(request): + """ Returns information that wil populate the user info cookie. """ + user = request.user + # Set a cookie with user info. This can be used by external sites # to customize content based on user information. Currently, # we include information that's used to customize the "account" @@ -94,38 +140,17 @@ def set_logged_in_cookies(request, response, user): pass # Convert relative URL paths to absolute URIs - for url_name, url_path in header_urls.iteritems(): + for url_name, url_path in six.iteritems(header_urls): header_urls[url_name] = request.build_absolute_uri(url_path) user_info = { 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, 'username': user.username, - 'email': user.email, 'header_urls': header_urls, + 'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(user) } - # In production, TLS should be enabled so that this cookie is encrypted - # when we send it. We also need to set "secure" to True so that the browser - # will transmit it only over secure connections. - # - # In non-production environments (acceptance tests, devstack, and sandboxes), - # we still want to set this cookie. However, we do NOT want to set it to "secure" - # because the browser won't send it back to us. This can cause an infinite redirect - # loop in the third-party auth flow, which calls `is_logged_in_cookie_set` to determine - # whether it needs to set the cookie or continue to the next pipeline stage. - user_info_cookie_is_secure = request.is_secure() - - response.set_cookie( - settings.EDXMKTG_USER_INFO_COOKIE_NAME.encode('utf-8'), - json.dumps(user_info), - secure=user_info_cookie_is_secure, - **cookie_settings - ) - - # give signal receivers a chance to add cookies - CREATE_LOGON_COOKIE.send(sender=None, user=user, response=response) - - return response + return user_info def delete_logged_in_cookies(response): diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index e262f16091..7d8fa67beb 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -10,55 +10,53 @@ file and check it in at the same time as your model changes. To do that, 2. ./manage.py lms schemamigration student --auto description_of_your_change 3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/ """ +import hashlib +import json +import logging +import uuid from collections import defaultdict, OrderedDict, namedtuple from datetime import datetime, timedelta from functools import total_ordering -import hashlib from importlib import import_module -import json -import logging -from pytz import UTC from urllib import urlencode -import uuid import analytics - +import dogstats_wrapper as dog_stats_api from config_models.models import ConfigurationModel -from django.utils.translation import ugettext_lazy as _ from django.conf import settings -from django.utils import timezone -from django.contrib.auth.models import User from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import User from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.db import models, IntegrityError, transaction from django.db.models import Count from django.db.models.signals import pre_save, post_save from django.dispatch import receiver, Signal -from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_noop -from django.core.cache import cache from django_countries.fields import CountryField -import dogstats_wrapper as dog_stats_api from eventtracking import tracker from model_utils.models import TimeStampedModel from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey +from pytz import UTC from simple_history.models import HistoricalRecords -from track import contexts -from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager +import lms.lib.comment_client as cc +import request_cache from certificates.models import GeneratedCertificate from course_modes.models import CourseMode from enrollment.api import _default_course_mode -import lms.lib.comment_client as cc from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -import request_cache from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager +from track import contexts +from util.milestones_helpers import is_entrance_exams_enabled from util.model_utils import emit_field_changed_events, get_changed_fields_dict from util.query import use_read_replica_if_available -from util.milestones_helpers import is_entrance_exams_enabled - UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"]) ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"]) @@ -1030,6 +1028,13 @@ class CourseEnrollment(models.Model): "[CourseEnrollment] {}: {} ({}); active: ({})" ).format(self.user, self.course_id, self.created, self.is_active) + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + super(CourseEnrollment, self).save(force_insert=force_insert, force_update=force_update, using=using, + update_fields=update_fields) + + # Delete the cached status hash, forcing the value to be recalculated the next time it is needed. + cache.delete(self.enrollment_status_hash_cache_key(self.user)) + @classmethod @transaction.atomic def get_or_create_enrollment(cls, user, course_key): @@ -1449,6 +1454,47 @@ class CourseEnrollment(models.Model): def enrollments_for_user(cls, user): return cls.objects.filter(user=user, is_active=1) + @classmethod + def enrollment_status_hash_cache_key(cls, user): + """ Returns the cache key for the cached enrollment status hash. + + Args: + user (User): User whose cache key should be returned. + + Returns: + str: Cache key. + """ + return 'enrollment_status_hash_' + user.username + + @classmethod + def generate_enrollment_status_hash(cls, user): + """ Generates a hash encoding the given user's *active* enrollments. + + Args: + user (User): User whose enrollments should be hashed. + + Returns: + str: Hash of the user's active enrollments. If the user is anonymous, `None` is returned. + """ + if user.is_anonymous(): + return None + + cache_key = cls.enrollment_status_hash_cache_key(user) + status_hash = cache.get(cache_key) + + if not status_hash: + enrollments = cls.enrollments_for_user(user).values_list('course_id', 'mode') + enrollments = [(e[0].lower(), e[1].lower()) for e in enrollments] + enrollments = sorted(enrollments, key=lambda e: e[0]) + hash_elements = [user.username] + hash_elements += ['{course_id}={mode}'.format(course_id=e[0], mode=e[1]) for e in enrollments] + status_hash = hashlib.md5('&'.join(hash_elements).encode('utf-8')).hexdigest() + + # The hash is cached indefinitely. It will be invalidated when the user enrolls/unenrolls. + cache.set(cache_key, status_hash, None) + + return status_hash + def is_paid_course(self): """ Returns True, if course is paid diff --git a/common/djangoapps/student/tests/test_cookies.py b/common/djangoapps/student/tests/test_cookies.py new file mode 100644 index 0000000000..f1ca250d86 --- /dev/null +++ b/common/djangoapps/student/tests/test_cookies.py @@ -0,0 +1,57 @@ +# pylint: disable=missing-docstring +from __future__ import unicode_literals + +import six +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test import RequestFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from student.cookies import get_user_info_cookie_data +from student.models import CourseEnrollment +from student.tests.factories import UserFactory + + +class CookieTests(SharedModuleStoreTestCase): + @classmethod + def setUpClass(cls): + super(CookieTests, cls).setUpClass() + cls.course = CourseFactory() + + def setUp(self): + super(CookieTests, self).setUp() + self.user = UserFactory.create() + + def _get_expected_header_urls(self, request): + expected_header_urls = { + 'logout': reverse('logout'), + } + + # Studio (CMS) does not have the URLs below + if settings.ROOT_URLCONF == 'lms.urls': + expected_header_urls.update({ + 'account_settings': reverse('account_settings'), + 'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}), + }) + + # Convert relative URL paths to absolute URIs + for url_name, url_path in six.iteritems(expected_header_urls): + expected_header_urls[url_name] = request.build_absolute_uri(url_path) + + return expected_header_urls + + def test_get_user_info_cookie_data(self): + request = RequestFactory().get('/') + request.user = self.user + + actual = get_user_info_cookie_data(request) + + expected = { + 'version': settings.EDXMKTG_USER_INFO_COOKIE_VERSION, + 'username': self.user.username, + 'header_urls': self._get_expected_header_urls(request), + 'enrollmentStatusHash': CourseEnrollment.generate_enrollment_status_hash(self.user) + } + + self.assertDictEqual(actual, expected) diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 3699289cd6..03e3e7c2a0 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -1,6 +1,6 @@ -''' +""" Tests for student activation and login -''' +""" import json import unittest @@ -30,9 +30,9 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase class LoginTest(CacheIsolationTestCase): - ''' + """ Test student.views.login_user() view - ''' + """ ENABLED_CACHES = ['default'] @@ -171,12 +171,8 @@ class LoginTest(CacheIsolationTestCase): cookie = self.client.cookies[settings.EDXMKTG_USER_INFO_COOKIE_NAME] user_info = json.loads(cookie.value) - # Check that the version is set self.assertEqual(user_info["version"], settings.EDXMKTG_USER_INFO_COOKIE_VERSION) - - # Check that the username and email are set self.assertEqual(user_info["username"], self.user.username) - self.assertEqual(user_info["email"], self.user.email) # Check that the URLs are absolute for url in user_info["header_urls"].values(): diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py new file mode 100644 index 0000000000..42efec8f80 --- /dev/null +++ b/common/djangoapps/student/tests/test_models.py @@ -0,0 +1,84 @@ +# pylint: disable=missing-docstring + +import hashlib + +from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache +from django.db.models.functions import Lower +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from student.models import CourseEnrollment +from student.tests.factories import UserFactory, CourseEnrollmentFactory + + +class CourseEnrollmentTests(SharedModuleStoreTestCase): + @classmethod + def setUpClass(cls): + super(CourseEnrollmentTests, cls).setUpClass() + cls.course = CourseFactory() + + def setUp(self): + super(CourseEnrollmentTests, self).setUp() + self.user = UserFactory.create() + + def test_enrollment_status_hash_cache_key(self): + username = 'test-user' + user = UserFactory(username=username) + expected = 'enrollment_status_hash_' + username + self.assertEqual(CourseEnrollment.enrollment_status_hash_cache_key(user), expected) + + def assert_enrollment_status_hash_cached(self, user, expected_value): + self.assertEqual(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(user)), expected_value) + + def test_generate_enrollment_status_hash(self): + """ Verify the method returns a hash of a user's current enrollments. """ + # Return None for anonymous users + self.assertIsNone(CourseEnrollment.generate_enrollment_status_hash(AnonymousUser())) + + # No enrollments + expected = hashlib.md5(self.user.username).hexdigest() + self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected) + self.assert_enrollment_status_hash_cached(self.user, expected) + + # No active enrollments + enrollment_mode = 'verified' + course_id = self.course.id # pylint: disable=no-member + enrollment = CourseEnrollmentFactory.create(user=self.user, course_id=course_id, mode=enrollment_mode, + is_active=False) + self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected) + self.assert_enrollment_status_hash_cached(self.user, expected) + + # One active enrollment + enrollment.is_active = True + enrollment.save() + expected = '{username}&{course_id}={mode}'.format( + username=self.user.username, course_id=str(course_id).lower(), mode=enrollment_mode.lower() + ) + expected = hashlib.md5(expected).hexdigest() + self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected) + self.assert_enrollment_status_hash_cached(self.user, expected) + + # Multiple enrollments + CourseEnrollmentFactory.create(user=self.user) + enrollments = CourseEnrollment.enrollments_for_user(self.user).order_by(Lower('course_id')) + hash_elements = [self.user.username] + hash_elements += [ + '{course_id}={mode}'.format(course_id=str(enrollment.course_id).lower(), mode=enrollment.mode.lower()) for + enrollment in enrollments] + expected = hashlib.md5('&'.join(hash_elements)).hexdigest() + self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected) + self.assert_enrollment_status_hash_cached(self.user, expected) + + def test_save_deletes_cached_enrollment_status_hash(self): + """ Verify the method deletes the cached enrollment status hash for the user. """ + # There should be no cached value for a new user with no enrollments. + self.assertIsNone(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(self.user))) + + # Generating a status hash should cache the generated value. + status_hash = CourseEnrollment.generate_enrollment_status_hash(self.user) + self.assert_enrollment_status_hash_cached(self.user, status_hash) + + # Modifying enrollments should delete the cached value. + CourseEnrollmentFactory.create(user=self.user) + self.assertIsNone(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(self.user))) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0e5cf4c884..3874d07dda 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -99,6 +99,7 @@ rules==1.1.1 scipy==0.14.0 Shapely==1.2.16 singledispatch==3.4.0.2 +six>=1.10.0,<2.0.0 sorl-thumbnail==12.3 sortedcontainers==0.9.2 stevedore==1.10.0