From 1205173d6f8b3d88e718b8e86da1c44e4b87a4b2 Mon Sep 17 00:00:00 2001
From: Calen Pennington <cale@edx.org>
Date: Tue, 26 Nov 2013 15:18:24 -0500
Subject: [PATCH] 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
---
 .../commands/anonymized_id_mapping.py         |  22 +-
 ...e_between_user_and_anonymous_student_id.py | 194 ++++++++++++++++++
 common/djangoapps/student/models.py           |  67 +++++-
 common/djangoapps/student/tests/tests.py      |  38 +++-
 .../courseware/tests/test_module_render.py    |  76 +++++++
 5 files changed, 381 insertions(+), 16 deletions(-)
 create mode 100644 common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py

diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
index f1ed5bdef90..3c066121c59 100644
--- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py
+++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py
@@ -1,21 +1,19 @@
 # -*- 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
 with a simple Django management command to generate a CSV mapping. To run, use
 the following:
 
-rake django-admin[anonymized_id_mapping,x,y,z]
-
-[Naturally, substitute the appropriate values for x, y, and z. (I.e.,
- lms, dev, and MITx/6.002x/Circuits)]"""
+./manage.py lms anonymized_id_mapping COURSE_ID
+"""
 
 import csv
 
 from django.contrib.auth.models import User
 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):
@@ -52,9 +50,17 @@ class Command(BaseCommand):
         try:
             with open(output_filename, 'wb') as 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:
-                    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:
             raise CommandError("Error writing to file: %s" % output_filename)
 
diff --git a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py
new file mode 100644
index 00000000000..e086496bfcc
--- /dev/null
+++ b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding model 'AnonymousUserId'
+        db.create_table('student_anonymoususerid', (
+            ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+            ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+            ('anonymous_user_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=16)),
+            ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
+        ))
+        db.send_create_signal('student', ['AnonymousUserId'])
+
+
+    def backwards(self, orm):
+        # Deleting model 'AnonymousUserId'
+        db.delete_table('student_anonymoususerid')
+
+
+    models = {
+        'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        'auth.permission': {
+            'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        'student.anonymoususerid': {
+            'Meta': {'object_name': 'AnonymousUserId'},
+            'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16'}),
+            'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'student.courseenrollment': {
+            'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
+            'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+        },
+        'student.courseenrollmentallowed': {
+            'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
+            'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+        },
+        'student.pendingemailchange': {
+            'Meta': {'object_name': 'PendingEmailChange'},
+            'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+        },
+        'student.pendingnamechange': {
+            'Meta': {'object_name': 'PendingNameChange'},
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+        },
+        'student.registration': {
+            'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
+            'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
+        },
+        'student.testcenterregistration': {
+            'Meta': {'object_name': 'TestCenterRegistration'},
+            'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
+            'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+            'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+            'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
+            'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+            'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
+            'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+            'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
+            'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+            'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
+            'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+            'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+            'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+            'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+        },
+        'student.testcenteruser': {
+            'Meta': {'object_name': 'TestCenterUser'},
+            'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+            'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+            'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+            'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+            'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+            'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
+            'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
+            'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+            'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+            'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
+            'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
+            'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+            'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
+            'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+            'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
+            'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
+            'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+            'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+            'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+            'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
+            'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+            'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
+            'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+        },
+        'student.userprofile': {
+            'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
+            'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            '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'}),
+            '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'}),
+            'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+            'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+            'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
+            'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+        },
+        'student.userstanding': {
+            'Meta': {'object_name': 'UserStanding'},
+            'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}),
+            'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"})
+        },
+        'student.usertestgroup': {
+            'Meta': {'object_name': 'UserTestGroup'},
+            'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+            'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
+        }
+    }
+
+    complete_apps = ['student']
\ No newline at end of file
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 67746326348..15cc802b07b 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -25,6 +25,7 @@ from django.db.models.signals import post_save
 from django.dispatch import receiver
 import django.dispatch
 from django.forms import ModelForm, forms
+from django.core.exceptions import ObjectDoesNotExist
 
 from course_modes.models import CourseMode
 import lms.lib.comment_client as cc
@@ -42,6 +43,63 @@ log = logging.getLogger(__name__)
 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):
     """
     This table contains a student's account's status.
@@ -624,12 +682,9 @@ def unique_id_for_user(user):
     Return a unique id for a user, suitable for inserting into
     e.g. personalized survey links.
     """
-    # include the secret key as a salt, and to make the ids unique across
-    # different LMS installs.
-    h = hashlib.md5()
-    h.update(settings.SECRET_KEY)
-    h.update(str(user.id))
-    return h.hexdigest()
+    # Setting course_id to '' makes it not affect the generated hash,
+    # and thus produce the old per-student anonymous id
+    return anonymous_id_for_user(user, '')
 
 
 # TODO: Should be renamed to generic UserGroup, and possibly
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 1e7ee4baa89..f6f32d81b32 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -15,7 +15,7 @@ from django.conf import settings
 from django.test import TestCase
 from django.test.utils import override_settings
 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.tokens import default_token_generator
 from django.utils.http import int_to_base36
@@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE
 from mock import Mock, patch, sentinel
 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,
                            change_enrollment, complete_course_mode_info)
 from student.tests.factories import UserFactory, CourseModeFactory
@@ -501,3 +501,37 @@ class PaidRegistrationTest(ModuleStoreTestCase):
         self.assertEqual(response.content, reverse('shoppingcart.views.show_cart'))
         self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order(
             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)
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index 38f42d1f49b..a156b0aea39 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -1,6 +1,7 @@
 """
 Test for lms courseware app, module render unit
 """
+from ddt import ddt, data
 from mock import MagicMock, patch, Mock
 import json
 
@@ -11,10 +12,14 @@ from django.test import TestCase
 from django.test.client import RequestFactory
 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 import Location
 from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.x_module import XModuleDescriptor
 import courseware.module_render as render
 from courseware.tests.tests import LoginEnrollmentTestCase
 from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
@@ -515,3 +520,74 @@ class TestHtmlModifiers(ModuleStoreTestCase):
             'Staff Debug',
             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)
+        )
-- 
GitLab