Commit 1205173d by Calen Pennington

Add a per-course anonymous student id

This does not yet replace the existing per-student anonymous id, but
is intended to do so in the future.

Co-author: Alexander Kryklia <kryklia@edx.org>
Co-author: Ned Batchelder <ned@edx.org>
Co-author: Oleg Marchev <oleg@edx.org>
Co-author: Valera Rozuvan <valera@edx.org>
Co-author: polesye
parent 9b6edea4
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Dump username,unique_id_for_user pairs as CSV. """Dump username, per-student anonymous id, and per-course anonymous id triples as CSV.
Give instructors easy access to the mapping from anonymized IDs to user IDs Give instructors easy access to the mapping from anonymized IDs to user IDs
with a simple Django management command to generate a CSV mapping. To run, use with a simple Django management command to generate a CSV mapping. To run, use
the following: the following:
rake django-admin[anonymized_id_mapping,x,y,z] ./manage.py lms anonymized_id_mapping COURSE_ID
"""
[Naturally, substitute the appropriate values for x, y, and z. (I.e.,
lms, dev, and MITx/6.002x/Circuits)]"""
import csv import csv
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from student.models import unique_id_for_user from student.models import anonymous_id_for_user
class Command(BaseCommand): class Command(BaseCommand):
...@@ -52,9 +50,17 @@ class Command(BaseCommand): ...@@ -52,9 +50,17 @@ class Command(BaseCommand):
try: try:
with open(output_filename, 'wb') as output_file: with open(output_filename, 'wb') as output_file:
csv_writer = csv.writer(output_file) csv_writer = csv.writer(output_file)
csv_writer.writerow(("User ID", "Anonymized user ID")) csv_writer.writerow((
"User ID",
"Per-Student anonymized user ID",
"Per-course anonymized user id"
))
for student in students: for student in students:
csv_writer.writerow((student.id, unique_id_for_user(student))) csv_writer.writerow((
student.id,
anonymous_id_for_user(student, ''),
anonymous_id_for_user(student, course_id)
))
except IOError: except IOError:
raise CommandError("Error writing to file: %s" % output_filename) raise CommandError("Error writing to file: %s" % output_filename)
...@@ -25,6 +25,7 @@ from django.db.models.signals import post_save ...@@ -25,6 +25,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
import django.dispatch import django.dispatch
from django.forms import ModelForm, forms from django.forms import ModelForm, forms
from django.core.exceptions import ObjectDoesNotExist
from course_modes.models import CourseMode from course_modes.models import CourseMode
import lms.lib.comment_client as cc import lms.lib.comment_client as cc
...@@ -42,6 +43,63 @@ log = logging.getLogger(__name__) ...@@ -42,6 +43,63 @@ log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
class AnonymousUserId(models.Model):
"""
This table contains user, course_Id and anonymous_user_id
Purpose of this table is to provide user by anonymous_user_id.
We are generating anonymous_user_id using md5 algorithm, so resulting length will always be 16 bytes.
http://docs.python.org/2/library/md5.html#md5.digest_size
"""
user = models.ForeignKey(User, db_index=True)
anonymous_user_id = models.CharField(unique=True, max_length=16)
course_id = models.CharField(db_index=True, max_length=255)
unique_together = (user, course_id)
def anonymous_id_for_user(user, course_id):
"""
Return a unique id for a (user, course) pair, suitable for inserting
into e.g. personalized survey links.
If user is an `AnonymousUser`, returns `None`
"""
# This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated.
if user.is_anonymous():
return None
# include the secret key as a salt, and to make the ids unique across different LMS installs.
hasher = hashlib.md5()
hasher.update(settings.SECRET_KEY)
hasher.update(str(user.id))
hasher.update(course_id)
return AnonymousUserId.objects.get_or_create(
defaults={'anonymous_user_id': hasher.hexdigest()},
user=user,
course_id=course_id
)[0].anonymous_user_id
def user_by_anonymous_id(id):
"""
Return user by anonymous_user_id using AnonymousUserId lookup table.
Do not raise `django.ObjectDoesNotExist` exception,
if there is no user for anonymous_student_id,
because this function will be used inside xmodule w/o django access.
"""
if id is None:
return None
try:
return User.objects.get(anonymoususerid__anonymous_user_id=id)
except ObjectDoesNotExist:
return None
class UserStanding(models.Model): class UserStanding(models.Model):
""" """
This table contains a student's account's status. This table contains a student's account's status.
...@@ -624,12 +682,9 @@ def unique_id_for_user(user): ...@@ -624,12 +682,9 @@ def unique_id_for_user(user):
Return a unique id for a user, suitable for inserting into Return a unique id for a user, suitable for inserting into
e.g. personalized survey links. e.g. personalized survey links.
""" """
# include the secret key as a salt, and to make the ids unique across # Setting course_id to '' makes it not affect the generated hash,
# different LMS installs. # and thus produce the old per-student anonymous id
h = hashlib.md5() return anonymous_id_for_user(user, '')
h.update(settings.SECRET_KEY)
h.update(str(user.id))
return h.hexdigest()
# TODO: Should be renamed to generic UserGroup, and possibly # TODO: Should be renamed to generic UserGroup, and possibly
......
...@@ -15,7 +15,7 @@ from django.conf import settings ...@@ -15,7 +15,7 @@ from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.contrib.auth.models import User from django.contrib.auth.models import User, AnonymousUser
from django.contrib.auth.hashers import UNUSABLE_PASSWORD from django.contrib.auth.hashers import UNUSABLE_PASSWORD
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.utils.http import int_to_base36 from django.utils.http import int_to_base36
...@@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE ...@@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
from mock import Mock, patch, sentinel from mock import Mock, patch, sentinel
from textwrap import dedent from textwrap import dedent
from student.models import unique_id_for_user, CourseEnrollment from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper,
change_enrollment, complete_course_mode_info) change_enrollment, complete_course_mode_info)
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
...@@ -501,3 +501,37 @@ class PaidRegistrationTest(ModuleStoreTestCase): ...@@ -501,3 +501,37 @@ class PaidRegistrationTest(ModuleStoreTestCase):
self.assertEqual(response.content, reverse('shoppingcart.views.show_cart')) self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order( self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id)) shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id))
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class AnonymousLookupTable(TestCase):
"""
Tests for anonymous_id_functions
"""
# arbitrary constant
COURSE_SLUG = "100"
COURSE_NAME = "test_course"
COURSE_ORG = "EDX"
def setUp(self):
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.assertIsNotNone(self.course)
self.user = UserFactory()
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor Code',
)
patcher = patch('student.models.server_track')
self.mock_server_track = patcher.start()
self.addCleanup(patcher.stop)
def test_for_unregistered_user(self): # same path as for logged out user
self.assertEqual(None, anonymous_id_for_user(AnonymousUser(), self.course.id))
self.assertIsNone(user_by_anonymous_id(None))
def test_roundtrip_for_logged_user(self):
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
real_user = user_by_anonymous_id(anonymous_id)
self.assertEqual(self.user, real_user)
""" """
Test for lms courseware app, module render unit Test for lms courseware app, module render unit
""" """
from ddt import ddt, data
from mock import MagicMock, patch, Mock from mock import MagicMock, patch, Mock
import json import json
...@@ -11,10 +12,14 @@ from django.test import TestCase ...@@ -11,10 +12,14 @@ from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from xblock.field_data import FieldData
from xblock.runtime import Runtime
from xblock.fields import ScopeIds
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.x_module import XModuleDescriptor
import courseware.module_render as render import courseware.module_render as render
from courseware.tests.tests import LoginEnrollmentTestCase from courseware.tests.tests import LoginEnrollmentTestCase
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
...@@ -515,3 +520,74 @@ class TestHtmlModifiers(ModuleStoreTestCase): ...@@ -515,3 +520,74 @@ class TestHtmlModifiers(ModuleStoreTestCase):
'Staff Debug', 'Staff Debug',
result_fragment.content result_fragment.content
) )
PER_COURSE_ANONYMIZED_DESCRIPTORS = ()
PER_STUDENT_ANONYMIZED_DESCRIPTORS = [
class_ for (name, class_) in XModuleDescriptor.load_classes()
if not issubclass(class_, PER_COURSE_ANONYMIZED_DESCRIPTORS)
]
@ddt
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestAnonymousStudentId(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test that anonymous_student_id is set correctly across a variety of XBlock types
"""
def setUp(self):
self.user = UserFactory()
@patch('courseware.module_render.has_access', Mock(return_value=True))
def _get_anonymous_id(self, course_id, xblock_class):
location = Location('dummy_org', 'dummy_course', 'dummy_category', 'dummy_name')
descriptor = Mock(
spec=xblock_class,
_field_data=Mock(spec=FieldData),
location=location,
static_asset_path=None,
runtime=Mock(
spec=Runtime,
resources_fs=None,
mixologist=Mock(_mixins=())
),
scope_ids=Mock(spec=ScopeIds),
)
if hasattr(xblock_class, 'module_class'):
descriptor.module_class = xblock_class.module_class
return render.get_module_for_descriptor_internal(
self.user,
descriptor,
Mock(spec=FieldDataCache),
course_id,
Mock(), # Track Function
Mock(), # XQueue Callback Url Prefix
).xmodule_runtime.anonymous_student_id
@data(*PER_STUDENT_ANONYMIZED_DESCRIPTORS)
def test_per_student_anonymized_id(self, descriptor_class):
for course_id in ('MITx/6.00x/2012_Fall', 'MITx/6.00x/2013_Spring'):
self.assertEquals(
# This value is set by observation, so that later changes to the student
# id computation don't break old data
'5afe5d9bb03796557ee2614f5c9611fb',
self._get_anonymous_id(course_id, descriptor_class)
)
@data(*PER_COURSE_ANONYMIZED_DESCRIPTORS)
def test_per_course_anonymized_id(self, descriptor_class):
self.assertEquals(
# This value is set by observation, so that later changes to the student
# id computation don't break old data
'e3b0b940318df9c14be59acb08e78af5',
self._get_anonymous_id('MITx/6.00x/2012_Fall', descriptor_class)
)
self.assertEquals(
# This value is set by observation, so that later changes to the student
# id computation don't break old data
'f82b5416c9f54b5ce33989511bb5ef2e',
self._get_anonymous_id('MITx/6.00x/2013_Spring', descriptor_class)
)
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