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