models.py 15 KB
Newer Older
1
"""
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
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.)

20 21 22 23 24 25 26
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

27
Migration Notes
28 29 30 31 32

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,

1. Go to the mitx dir
33 34
2. django-admin.py schemamigration student --auto --settings=lms.envs.dev --pythonpath=. description_of_your_change
3. Add the migration file created in mitx/common/djangoapps/student/migrations/
35
"""
36 37
from datetime import datetime
import json
38
import logging
39 40
import uuid

41
from django.conf import settings
42
from django.contrib.auth.models import User
43 44 45
from django.db import models
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
46
from django_countries import CountryField
47

48 49
from xmodule.modulestore.django import modulestore

Piotr Mitros committed
50
#from cache_toolbox import cache_model, cache_relation
51

52
log = logging.getLogger(__name__)
53

54
class UserProfile(models.Model):
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
    """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.
    """

73 74 75 76
    class Meta:
        db_table = "auth_userprofile"

    ## CRITICAL TODO/SECURITY
77
    # Sanitize all fields.
78
    # This is not visible to other users, but could introduce holes later
79
    user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile')
80
    name = models.CharField(blank=True, max_length=255, db_index=True)
81

82
    meta = models.TextField(blank=True)  # JSON dictionary for future expansion
83
    courseware = models.CharField(blank=True, max_length=255, default='course.xml')
84 85 86 87 88 89 90

    # Location is no longer used, but is held here for backwards compatibility
    # for users imported from our first class.
    language = models.CharField(blank=True, max_length=255, db_index=True)
    location = models.CharField(blank=True, max_length=255, db_index=True)

    # Optional demographic data we started capturing from Fall 2012
91 92
    this_year = datetime.now().year
    VALID_YEARS = range(this_year, this_year - 120, -1)
93 94 95 96 97 98 99
    year_of_birth = models.IntegerField(blank=True, null=True, db_index=True)
    GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other'))
    gender = models.CharField(blank=True, null=True, max_length=6, db_index=True,
                              choices=GENDER_CHOICES)
    LEVEL_OF_EDUCATION_CHOICES = (('p_se', 'Doctorate in science or engineering'),
                                  ('p_oth', 'Doctorate in another field'),
                                  ('m', "Master's or professional degree"),
100
                                  ('b', "Bachelor's degree"),
101 102 103 104 105 106 107 108 109
                                  ('hs', "Secondary/high school"),
                                  ('jhs', "Junior secondary/junior high/middle school"),
                                  ('el', "Elementary/primary school"),
                                  ('none', "None"),
                                  ('other', "Other"))
    level_of_education = models.CharField(
                            blank=True, null=True, max_length=6, db_index=True,
                            choices=LEVEL_OF_EDUCATION_CHOICES
                         )
110
    mailing_address = models.TextField(blank=True, null=True)
111 112
    goals = models.TextField(blank=True, null=True)

113
    def get_meta(self):
114
        js_str = self.meta
115
        if not js_str:
116
            js_str = dict()
117
        else:
118
            js_str = json.loads(self.meta)
119

120
        return js_str
121

122
    def set_meta(self, js):
123 124
        self.meta = json.dumps(js)

125

Piotr Mitros committed
126 127
## TODO: Should be renamed to generic UserGroup, and possibly
# Given an optional field for type of group
Piotr Mitros committed
128 129 130 131
class UserTestGroup(models.Model):
    users = models.ManyToManyField(User, db_index=True)
    name = models.CharField(blank=False, max_length=32, db_index=True)
    description = models.TextField(blank=True)
132

133

134 135
class Registration(models.Model):
    ''' Allows us to wait for e-mail before user is registered. A
136
        registration profile is created when the user creates an
137 138 139 140 141 142 143 144 145 146
        account, but that account is inactive. Once the user clicks
        on the activation key, it becomes active. '''
    class Meta:
        db_table = "auth_registration"

    user = models.ForeignKey(User, unique=True)
    activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)

    def register(self, user):
        # MINOR TODO: Switch to crypto-secure key
147 148
        self.activation_key = uuid.uuid4().hex
        self.user = user
149 150 151 152 153
        self.save()

    def activate(self):
        self.user.is_active = True
        self.user.save()
154
        #self.delete()
155

156

157
class PendingNameChange(models.Model):
Piotr Mitros committed
158 159 160
    user = models.OneToOneField(User, unique=True, db_index=True)
    new_name = models.CharField(blank=True, max_length=255)
    rationale = models.CharField(blank=True, max_length=1024)
161

162

163
class PendingEmailChange(models.Model):
Piotr Mitros committed
164
    user = models.OneToOneField(User, unique=True, db_index=True)
165
    new_email = models.CharField(blank=True, max_length=255, db_index=True)
166
    activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
167

168

169
class CourseEnrollment(models.Model):
170 171
    user = models.ForeignKey(User)
    course_id = models.CharField(max_length=255, db_index=True)
172

173
    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
174

175 176
    class Meta:
        unique_together = (('user', 'course_id'), )
177

178
#cache_relation(User.profile)
179

180
#### Helper methods for use from python manage.py shell.
181

182

183
def get_user(email):
184 185 186 187
    u = User.objects.get(email=email)
    up = UserProfile.objects.get(user=u)
    return u, up

188 189

def user_info(email):
190
    u, up = get_user(email)
191 192 193 194 195 196
    print "User id", u.id
    print "Username", u.username
    print "E-mail", u.email
    print "Name", up.name
    print "Location", up.location
    print "Language", up.language
197 198
    return u, up

199 200

def change_email(old_email, new_email):
201
    u = User.objects.get(email=old_email)
202 203 204
    u.email = new_email
    u.save()

205

206
def change_name(email, new_name):
207
    u, up = get_user(email)
208 209 210
    up.name = new_name
    up.save()

211

Piotr Mitros committed
212
def user_count():
Piotr Mitros committed
213
    print "All users", User.objects.all().count()
214
    print "Active users", User.objects.filter(is_active=True).count()
Piotr Mitros committed
215 216
    return User.objects.all().count()

217

Piotr Mitros committed
218
def active_user_count():
219 220
    return User.objects.filter(is_active=True).count()

Piotr Mitros committed
221

Piotr Mitros committed
222 223 224 225 226 227
def create_group(name, description):
    utg = UserTestGroup()
    utg.name = name
    utg.description = description
    utg.save()

228

229
def add_user_to_group(user, group):
230 231
    utg = UserTestGroup.objects.get(name=group)
    utg.users.add(User.objects.get(username=user))
Piotr Mitros committed
232
    utg.save()
233

234

235
def remove_user_from_group(user, group):
236 237
    utg = UserTestGroup.objects.get(name=group)
    utg.users.remove(User.objects.get(username=user))
238
    utg.save()
Piotr Mitros committed
239

240 241 242 243 244
default_groups = {'email_future_courses': 'Receive e-mails about future MITx courses',
                  'email_helpers': 'Receive e-mails about how to help with MITx',
                  'mitx_unenroll': 'Fully unenrolled -- no further communications',
                  '6002x_unenroll': 'Took and dropped 6002x'}

245 246 247

def add_user_to_default_group(user, group):
    try:
248
        utg = UserTestGroup.objects.get(name=group)
249
    except UserTestGroup.DoesNotExist:
250 251 252 253
        utg = UserTestGroup()
        utg.name = group
        utg.description = default_groups[group]
        utg.save()
254
    utg.users.add(User.objects.get(username=user))
Piotr Mitros committed
255
    utg.save()
256

257
########################## REPLICATION SIGNALS #################################
258
@receiver(post_save, sender=User)
David Ormsbee committed
259
def replicate_user_save(sender, **kwargs):
260 261 262 263 264
    user_obj = kwargs['instance']
    if not should_replicate(user_obj):
        return
    for course_db_name in db_names_to_replicate_to(user_obj.id):
        replicate_user(user_obj, course_db_name)
David Ormsbee committed
265

266 267 268 269 270 271 272 273 274 275 276
@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.
    """
David Ormsbee committed
277 278 279
    if not is_portal():
        return

280
    enrollment_obj = kwargs['instance']
281
    log.debug("Replicating user because of new enrollment")
282
    replicate_user(enrollment_obj.user, enrollment_obj.course_id)
283 284 285 286 287 288 289

    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)
David Ormsbee committed
290
 
291 292
@receiver(post_delete, sender=CourseEnrollment)
def replicate_enrollment_delete(sender, **kwargs):
293 294
    enrollment_obj = kwargs['instance']
    return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id)
David Ormsbee committed
295
 
296 297 298 299 300
@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']
301
    return replicate_model(UserProfile.save, user_profile_obj, user_profile_obj.user_id)
302

David Ormsbee committed
303
 
304
######### Replication functions #########
David Ormsbee committed
305 306 307 308
USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email",
                       "password", "is_staff", "is_active", "is_superuser",
                       "last_login", "date_joined"]

309 310 311 312
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
David Ormsbee committed
313 314
    the rest alone so that Askbot changes at the Course DB level don't get 
    overridden.
315 316
    """
    try:
David Ormsbee committed
317
        course_user = User.objects.using(course_db_name).get(id=portal_user.id)
318 319
        log.debug("User {0} found in Course DB, replicating fields to {1}"
                  .format(course_user, course_db_name))
320
    except User.DoesNotExist:
321 322
        log.debug("User {0} not found in Course DB, creating copy in {1}"
                  .format(portal_user, course_db_name))
323 324 325 326 327 328
        course_user = User()

    for field in USER_FIELDS_TO_COPY:
        setattr(course_user, field, getattr(portal_user, field))

    mark_handled(course_user)
David Ormsbee committed
329
    course_user.save(using=course_db_name)
330
    unmark(course_user)
331

332
def replicate_model(model_method, instance, user_id):
333 334 335 336 337 338 339 340 341 342 343
    """
    model_method is the model action that we want replicated. For instance, 
                 UserProfile.save
    """
    if not should_replicate(instance):
        return

    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))

344
    mark_handled(instance)
345 346
    for db_name in course_db_names:
        model_method(instance, using=db_name)
347
    unmark(instance)
348 349

######### Replication Helpers #########
350 351

def is_valid_course_id(course_id):
352 353 354 355 356 357
    """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.
    """
David Ormsbee committed
358 359
    return course_id != 'default'

360
def is_portal():
361 362 363
    """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."""
364 365
    return len(settings.DATABASES) > 1

366 367 368 369 370 371 372 373 374
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?"""
375
    return hasattr(instance, '_do_not_copy_to_course_db') and instance._do_not_copy_to_course_db
376 377 378 379 380 381 382 383 384 385 386 387

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

388 389 390 391 392
def unmark(instance):
    """If we don't unmark a model after we do replication, then consecutive 
    save() calls won't be properly replicated."""
    instance._do_not_copy_to_course_db = False

393 394 395 396 397
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 
David Ormsbee committed
398 399
        log.debug("{0} should not be replicated because it's been marked"
                  .format(instance))
400
        return False
401
    if not is_portal():
402 403 404 405
        log.debug("{0} should not be replicated because we're not a portal."
                  .format(instance))
        return False
    return True
406