diff --git a/cms/envs/test.py b/cms/envs/test.py index bce3c796cf..3823cd9dd9 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -55,6 +55,17 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ENV_ROOT / "db" / "cms.db", + }, + + # The following are for testing purposes... + 'edX/toy/2012_Fall': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course1.db", + }, + + 'edx/full/6.002_Spring_2012': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course2.db", } } diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 49d3381303..0fbe70c0b3 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1,5 +1,30 @@ """ -WE'RE USING MIGRATIONS! +Models for Student Information + +Replication Notes + +In our live deployment, we intend to run in a scenario where there is a pool of +Portal servers that hold the canoncial user information and that user +information is replicated to slave Course server pools. Each Course has a set of +servers that serves only its content and has users that are relevant only to it. + +We replicate the following tables into the Course DBs where the user is +enrolled. Only the Portal servers should ever write to these models. +* UserProfile +* CourseEnrollment + +We do a partial replication of: +* User -- Askbot extends this and uses the extra fields, so we replicate only + the stuff that comes with basic django_auth and ignore the rest.) + +There are a couple different scenarios: + +1. There's an update of User or UserProfile -- replicate it to all Course DBs + that the user is enrolled in (found via CourseEnrollment). +2. There's a change in CourseEnrollment. We need to push copies of UserProfile, + CourseEnrollment, and the base fields in User + +Migration Notes If you make changes to this model, be sure to create an appropriate migration file and check it in at the same time as your model changes. To do that, @@ -10,16 +35,41 @@ file and check it in at the same time as your model changes. To do that, """ from datetime import datetime import json +import logging import uuid -from django.db import models +from django.conf import settings from django.contrib.auth.models import User +from django.db import models +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver from django_countries import CountryField +from xmodule.modulestore.django import modulestore + #from cache_toolbox import cache_model, cache_relation +log = logging.getLogger(__name__) class UserProfile(models.Model): + """This is where we store all the user demographic fields. We have a + separate table for this rather than extending the built-in Django auth_user. + + Notes: + * Some fields are legacy ones from the first run of 6.002, from which + we imported many users. + * Fields like name and address are intentionally open ended, to account + for international variations. An unfortunate side-effect is that we + cannot efficiently sort on last names for instance. + + Replication: + * Only the Portal servers should ever modify this information. + * All fields are replicated into relevant Course databases + + Some of the fields are legacy ones that were captured during the initial + MITx fall prototype. + """ + class Meta: db_table = "auth_userprofile" @@ -203,3 +253,154 @@ def add_user_to_default_group(user, group): utg.save() utg.users.add(User.objects.get(username=user)) utg.save() + +########################## REPLICATION SIGNALS ################################# +@receiver(post_save, sender=User) +def replicate_user_save(sender, **kwargs): + user_obj = kwargs['instance'] + return replicate_model(User.save, user_obj, user_obj.id) + +@receiver(post_save, sender=CourseEnrollment) +def replicate_enrollment_save(sender, **kwargs): + """This is called when a Student enrolls in a course. It has to do the + following: + + 1. Make sure the User is copied into the Course DB. It may already exist + (someone deleting and re-adding a course). This has to happen first or + the foreign key constraint breaks. + 2. Replicate the CourseEnrollment. + 3. Replicate the UserProfile. + """ + if not is_portal(): + return + + enrollment_obj = kwargs['instance'] + log.debug("Replicating user because of new enrollment") + replicate_user(enrollment_obj.user, enrollment_obj.course_id) + + log.debug("Replicating enrollment because of new enrollment") + replicate_model(CourseEnrollment.save, enrollment_obj, enrollment_obj.user_id) + + log.debug("Replicating user profile because of new enrollment") + user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id) + replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id) + +@receiver(post_delete, sender=CourseEnrollment) +def replicate_enrollment_delete(sender, **kwargs): + enrollment_obj = kwargs['instance'] + return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) + +@receiver(post_save, sender=UserProfile) +def replicate_userprofile_save(sender, **kwargs): + """We just updated the UserProfile (say an update to the name), so push that + change to all Course DBs that we're enrolled in.""" + user_profile_obj = kwargs['instance'] + return replicate_model(UserProfile.save, user_profile_obj, user_profile_obj.user_id) + + +######### Replication functions ######### +USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email", + "password", "is_staff", "is_active", "is_superuser", + "last_login", "date_joined"] + +def replicate_user(portal_user, course_db_name): + """Replicate a User to the correct Course DB. This is more complicated than + it should be because Askbot extends the auth_user table and adds its own + fields. So we need to only push changes to the standard fields and leave + the rest alone so that Askbot changes at the Course DB level don't get + overridden. + """ + try: + # If the user exists in the Course DB, update the appropriate fields and + # save it back out to the Course DB. + course_user = User.objects.using(course_db_name).get(id=portal_user.id) + for field in USER_FIELDS_TO_COPY: + setattr(course_user, field, getattr(portal_user, field)) + + mark_handled(course_user) + log.debug("User {0} found in Course DB, replicating fields to {1}" + .format(course_user, course_db_name)) + course_user.save(using=course_db_name) # Just being explicit. + + except User.DoesNotExist: + # Otherwise, just make a straight copy to the Course DB. + mark_handled(portal_user) + log.debug("User {0} not found in Course DB, creating copy in {1}" + .format(portal_user, course_db_name)) + portal_user.save(using=course_db_name) + +def replicate_model(model_method, instance, user_id): + """ + model_method is the model action that we want replicated. For instance, + UserProfile.save + """ + if not should_replicate(instance): + return + + mark_handled(instance) + course_db_names = db_names_to_replicate_to(user_id) + log.debug("Replicating {0} for user {1} to DBs: {2}" + .format(model_method, user_id, course_db_names)) + + for db_name in course_db_names: + model_method(instance, using=db_name) + +######### Replication Helpers ######### + +def is_valid_course_id(course_id): + """Right now, the only database that's not a course database is 'default'. + I had nicer checking in here originally -- it would scan the courses that + were in the system and only let you choose that. But it was annoying to run + tests with, since we don't have course data for some for our course test + databases. Hence the lazy version. + """ + return course_id != 'default' + +def is_portal(): + """Are we in the portal pool? Only Portal servers are allowed to replicate + their changes. For now, only Portal servers see multiple DBs, so we use + that to decide.""" + return len(settings.DATABASES) > 1 + +def db_names_to_replicate_to(user_id): + """Return a list of DB names that this user_id is enrolled in.""" + return [c.course_id + for c in CourseEnrollment.objects.filter(user_id=user_id) + if is_valid_course_id(c.course_id)] + +def marked_handled(instance): + """Have we marked this instance as being handled to avoid infinite loops + caused by saving models in post_save hooks for the same models?""" + return hasattr(instance, '_do_not_copy_to_course_db') + +def mark_handled(instance): + """You have to mark your instance with this function or else we'll go into + an infinite loop since we're putting listeners on Model saves/deletes and + the act of replication requires us to call the same model method. + + We create a _replicated attribute to differentiate the first save of this + model vs. the duplicate save we force on to the course database. Kind of + a hack -- suggestions welcome. + """ + instance._do_not_copy_to_course_db = True + +def should_replicate(instance): + """Should this instance be replicated? We need to be a Portal server and + the instance has to not have been marked_handled.""" + if marked_handled(instance): + # Basically, avoid an infinite loop. You should + log.debug("{0} should not be replicated because it's been marked" + .format(instance)) + return False + if not is_portal(): + log.debug("{0} should not be replicated because we're not a portal." + .format(instance)) + return False + return True + + + + + + + diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index 501deb776c..ad7ddb70d1 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -4,13 +4,178 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ +from datetime import datetime from django.test import TestCase +from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY + +COURSE_1 = 'edX/toy/2012_Fall' +COURSE_2 = 'edx/full/6.002_Spring_2012' + +class ReplicationTest(TestCase): + + multi_db = True + + def test_user_replication(self): + """Test basic user replication.""" + portal_user = User.objects.create_user('rusty', 'rusty@edx.org', 'fakepass') + portal_user.first_name='Rusty' + portal_user.last_name='Skids' + portal_user.is_staff=True + portal_user.is_active=True + portal_user.is_superuser=True + portal_user.last_login=datetime(2012, 1, 1) + portal_user.date_joined=datetime(2011, 1, 1) + # This is an Askbot field and will break if askbot is not included + + if hasattr(portal_user, 'seen_response_count'): + portal_user.seen_response_count = 10 + + portal_user.save(using='default') + + # We replicate this user to Course 1, then pull the same user and verify + # that the fields copied over properly. + replicate_user(portal_user, COURSE_1) + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + + # Make sure the fields we care about got copied over for this user. + for field in USER_FIELDS_TO_COPY: + self.assertEqual(getattr(portal_user, field), + getattr(course_user, field), + "{0} not copied from {1} to {2}".format( + field, portal_user, course_user + )) + + if hasattr(portal_user, 'seen_response_count'): + # Since it's the first copy over of User data, we should have all of it + self.assertEqual(portal_user.seen_response_count, + course_user.seen_response_count) + + # But if we replicate again, the user already exists in the Course DB, + # so it shouldn't update the seen_response_count (which is Askbot + # controlled). + # This hasattr lameness is here because we don't want this test to be + # triggered when we're being run by CMS tests (Askbot doesn't exist + # there, so the test will fail). + if hasattr(portal_user, 'seen_response_count'): + portal_user.seen_response_count = 20 + replicate_user(portal_user, COURSE_1) + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.seen_response_count, 20) + self.assertEqual(course_user.seen_response_count, 10) + + # Another replication should work for an email change however, since + # it's a field we care about. + portal_user.email = "clyde@edx.org" + replicate_user(portal_user, COURSE_1) + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.email, course_user.email) + + # During this entire time, the user data should never have made it over + # to COURSE_2 + self.assertRaises(User.DoesNotExist, + User.objects.using(COURSE_2).get, + id=portal_user.id) + + + def test_enrollment_for_existing_user_info(self): + """Test the effect of Enrolling in a class if you've already got user + data to be copied over.""" + # Create our User + portal_user = User.objects.create_user('jack', 'jack@edx.org', 'fakepass') + portal_user.first_name = "Jack" + portal_user.save() + + # Set up our UserProfile info + portal_user_profile = UserProfile.objects.create( + user=portal_user, + name="Jack Foo", + level_of_education=None, + gender='m', + mailing_address=None, + goals="World domination", + ) + portal_user_profile.save() + + # Now let's see if creating a CourseEnrollment copies all the relevant + # data. + portal_enrollment = CourseEnrollment.objects.create(user=portal_user, + course_id=COURSE_1) + portal_enrollment.save() + + # Grab all the copies we expect + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEquals(portal_user, course_user) + self.assertRaises(User.DoesNotExist, + User.objects.using(COURSE_2).get, + id=portal_user.id) + + course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id) + self.assertEquals(portal_enrollment, course_enrollment) + self.assertRaises(CourseEnrollment.DoesNotExist, + CourseEnrollment.objects.using(COURSE_2).get, + id=portal_enrollment.id) + + course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id) + self.assertEquals(portal_user_profile, course_user_profile) + self.assertRaises(UserProfile.DoesNotExist, + UserProfile.objects.using(COURSE_2).get, + id=portal_user_profile.id) + + + def test_enrollment_for_user_info_after_enrollment(self): + """Test the effect of modifying User data after you've enrolled.""" + # Create our User + portal_user = User.objects.create_user('patty', 'patty@edx.org', 'fakepass') + portal_user.first_name = "Patty" + portal_user.save() + + # Set up our UserProfile info + portal_user_profile = UserProfile.objects.create( + user=portal_user, + name="Patty Foo", + level_of_education=None, + gender='f', + mailing_address=None, + goals="World peace", + ) + portal_user_profile.save() + + # Now let's see if creating a CourseEnrollment copies all the relevant + # data when things are saved. + portal_enrollment = CourseEnrollment.objects.create(user=portal_user, + course_id=COURSE_1) + portal_enrollment.save() + + portal_user.last_name = "Bar" + portal_user.save() + portal_user_profile.gender = 'm' + portal_user_profile.save() + + # Grab all the copies we expect, and make sure it doesn't end up in + # places we don't expect. + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEquals(portal_user, course_user) + self.assertRaises(User.DoesNotExist, + User.objects.using(COURSE_2).get, + id=portal_user.id) + + course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id) + self.assertEquals(portal_enrollment, course_enrollment) + self.assertRaises(CourseEnrollment.DoesNotExist, + CourseEnrollment.objects.using(COURSE_2).get, + id=portal_enrollment.id) + + course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id) + self.assertEquals(portal_user_profile, course_user_profile) + self.assertRaises(UserProfile.DoesNotExist, + UserProfile.objects.using(COURSE_2).get, + id=portal_user_profile.id) + + + + + + -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index e59e4bd68e..6c77127d7e 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -190,6 +190,13 @@ class Location(_LocationBase): return "Location%s" % repr(tuple(self)) + @property + def course_id(self): + """Return the ID of the Course that this item belongs to by looking + at the location URL hierachy""" + return "/".join([self.org, self.course, self.name]) + + class ModuleStore(object): """ An abstract interface for a database backend that stores XModuleDescriptor diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 0341febab2..0a47346a09 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -2,6 +2,7 @@ import json import logging from django.conf import settings +from django.core.urlresolvers import reverse from django.http import Http404 from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt @@ -148,12 +149,23 @@ def get_module(user, request, location, student_module_cache, position=None): # TODO (vshnayder): fix hardcoded urls (use reverse) # Setup system context for module instance - ajax_url = settings.MITX_ROOT_URL + '/modx/' + descriptor.location.url() + '/' + + ajax_url = reverse('modx_dispatch', + kwargs=dict(course_id=descriptor.location.course_id, + id=descriptor.location.url(), + dispatch=''), + ) + + # ajax_url = settings.MITX_ROOT_URL + '/modx/' + descriptor.location.url() + '/' # Fully qualified callback URL for external queueing system - xqueue_callback_url = (request.build_absolute_uri('/') + settings.MITX_ROOT_URL + - 'xqueue/' + str(user.id) + '/' + descriptor.location.url() + '/' + - 'score_update') + xqueue_callback_url = request.build_absolute_uri('/')[:-1] # Trailing slash provided by reverse + xqueue_callback_url += reverse('xqueue_callback', + kwargs=dict(course_id=descriptor.location.course_id, + userid=str(user.id), + id=descriptor.location.url(), + dispatch='score_update'), + ) # Default queuename is course-specific and is derived from the course that # contains the current module. @@ -259,7 +271,7 @@ def get_shared_instance_module(user, module, student_module_cache): return None @csrf_exempt -def xqueue_callback(request, userid, id, dispatch): +def xqueue_callback(request, course_id, userid, id, dispatch): ''' Entry point for graded results from the queueing system. ''' @@ -310,7 +322,7 @@ def xqueue_callback(request, userid, id, dispatch): return HttpResponse("") -def modx_dispatch(request, dispatch=None, id=None): +def modx_dispatch(request, dispatch=None, id=None, course_id=None): ''' Generic view for extensions. This is where AJAX calls go. Arguments: diff --git a/lms/envs/devgroups/__init__.py b/lms/envs/devgroups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/envs/devgroups/courses.py b/lms/envs/devgroups/courses.py new file mode 100644 index 0000000000..bcaae70a99 --- /dev/null +++ b/lms/envs/devgroups/courses.py @@ -0,0 +1,43 @@ +from ..dev import * + +CLASSES_TO_DBS = { + 'BerkeleyX/CS169.1x/2012_Fall' : "cs169.db", + 'BerkeleyX/CS188.1x/2012_Fall' : "cs188_1.db", + 'HarvardX/CS50x/2012' : "cs50.db", + 'HarvardX/PH207x/2012_Fall' : "ph207.db", + 'MITx/3.091x/2012_Fall' : "3091.db", + 'MITx/6.002x/2012_Fall' : "6002.db", + 'MITx/6.00x/2012_Fall' : "600.db", +} + + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211', + 'KEY_FUNCTION': 'util.memcache.safe_key', + }, + 'general': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211', + 'KEY_PREFIX' : 'general', + 'VERSION' : 5, + 'KEY_FUNCTION': 'util.memcache.safe_key', + } +} + +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' + + +def path_for_db(db_name): + return ENV_ROOT / "db" / db_name + +def course_db_for(course_id): + db_name = CLASSES_TO_DBS[course_id] + return { + 'default' : { + 'ENGINE' : 'django.db.backends.sqlite3', + 'NAME' : path_for_db(db_name) + } + } + diff --git a/lms/envs/devgroups/h_cs50.py b/lms/envs/devgroups/h_cs50.py new file mode 100644 index 0000000000..b838b1fdc3 --- /dev/null +++ b/lms/envs/devgroups/h_cs50.py @@ -0,0 +1,3 @@ +from .courses import * + +DATABASES = course_db_for('HarvardX/CS50x/2012') \ No newline at end of file diff --git a/lms/envs/devgroups/m_6002.py b/lms/envs/devgroups/m_6002.py new file mode 100644 index 0000000000..3d8feef764 --- /dev/null +++ b/lms/envs/devgroups/m_6002.py @@ -0,0 +1,3 @@ +from .courses import * + +DATABASES = course_db_for('MITx/6.002x/2012_Fall') \ No newline at end of file diff --git a/lms/envs/devgroups/portal.py b/lms/envs/devgroups/portal.py new file mode 100644 index 0000000000..b674218571 --- /dev/null +++ b/lms/envs/devgroups/portal.py @@ -0,0 +1,13 @@ +""" +Note that for this to work at all, you must have memcached running (or you won't +get shared sessions) +""" +from courses import * + +# Move this to a shared file later: +for class_id, db_name in CLASSES_TO_DBS.items(): + DATABASES[class_id] = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': path_for_db(db_name) + } + diff --git a/lms/envs/test.py b/lms/envs/test.py index ada87f0f80..cd0e984940 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -67,6 +67,17 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': PROJECT_ROOT / "db" / "mitx.db", + }, + + # The following are for testing purposes... + 'edX/toy/2012_Fall': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course1.db", + }, + + 'edx/full/6.002_Spring_2012': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ENV_ROOT / "db" / "course2.db", } } diff --git a/lms/templates/gradebook.html b/lms/templates/gradebook.html index 5b88a7f2b6..a4a81a6868 100644 --- a/lms/templates/gradebook.html +++ b/lms/templates/gradebook.html @@ -61,8 +61,8 @@ %for student in students:
- Grade summary + Grade summary diff --git a/lms/templates/profile.html b/lms/templates/profile.html index a6e28c6e57..ca27920a1b 100644 --- a/lms/templates/profile.html +++ b/lms/templates/profile.html @@ -137,7 +137,7 @@ $(function() { percentageString = "{0:.0%}".format( float(earned)/total) if earned > 0 and total > 0 else "" %> -
${section['format']}
diff --git a/lms/urls.py b/lms/urls.py
index 468ee8e62c..aaeba1b51e 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -2,7 +2,6 @@ from django.conf import settings
from django.conf.urls import patterns, include, url
from django.contrib import admin
from django.conf.urls.static import static
-
import django.contrib.auth.views
# Uncomment the next two lines to enable the admin:
@@ -101,10 +100,12 @@ if settings.COURSEWARE_ENABLED:
url(r'^masquerade/', include('masquerade.urls')),
url(r'^jump_to/(?P