models.py 45 KB
Newer Older
1
"""
2
Models for User Information (students, staff, etc)
3

4
Migration Notes
5 6 7 8

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,

9 10 11
1. Go to the edx-platform dir
2. ./manage.py lms schemamigration student --auto description_of_your_change
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
12
"""
13
from datetime import datetime, timedelta
14
import hashlib
15
import json
16
import logging
Julia Hansbrough committed
17
from pytz import UTC
18
import uuid
Julia Hansbrough committed
19
from collections import defaultdict
20
from dogapi import dog_stats_api
21 22
from django.db.models import Q
import pytz
23

24
from django.conf import settings
25
from django.utils import timezone
26
from django.contrib.auth.models import User
27
from django.contrib.auth.hashers import make_password
28
from django.contrib.auth.signals import user_logged_in, user_logged_out
29
from django.db import models, IntegrityError
Julia Hansbrough committed
30
from django.db.models import Count
Jay Zoldak committed
31
from django.dispatch import receiver, Signal
32
from django.core.exceptions import ObjectDoesNotExist
33
from django.utils.translation import ugettext_noop
34
from django_countries import CountryField
Gabe Mulley committed
35 36
from track import contexts
from eventtracking import tracker
37
from importlib import import_module
Gabe Mulley committed
38

39
from opaque_keys.edx.locations import SlashSeparatedCourseKey
40

Julia Hansbrough committed
41
import lms.lib.comment_client as cc
Julia Hansbrough committed
42
from util.query import use_read_replica_if_available
43
from xmodule_django.models import CourseKeyField, NoneToEmptyManager
44
from opaque_keys.edx.keys import CourseKey
45
from functools import total_ordering
Julia Hansbrough committed
46

47 48 49
from certificates.models import GeneratedCertificate
from course_modes.models import CourseMode

50 51
from ratelimitbackend import admin

52 53
import analytics

Jay Zoldak committed
54
unenroll_done = Signal(providing_args=["course_enrollment"])
55
log = logging.getLogger(__name__)
56
AUDIT_LOG = logging.getLogger("audit")
57
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
58

59

60 61 62 63 64 65
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.

66 67
    We generate anonymous_user_id using md5 algorithm,
    and use result in hex form, so its length is equal to 32 bytes.
68
    """
69 70 71

    objects = NoneToEmptyManager()

72
    user = models.ForeignKey(User, db_index=True)
73
    anonymous_user_id = models.CharField(unique=True, max_length=32)
74
    course_id = CourseKeyField(db_index=True, max_length=255, blank=True)
75 76 77
    unique_together = (user, course_id)


78
def anonymous_id_for_user(user, course_id, save=True):
79 80 81 82 83
    """
    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`
84 85 86

    Keyword arguments:
    save -- Whether the id should be saved in an AnonymousUserId object.
87 88 89 90 91
    """
    # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated.
    if user.is_anonymous():
        return None

92 93 94 95
    cached_id = getattr(user, '_anonymous_id', {}).get(course_id)
    if cached_id is not None:
        return cached_id

96 97 98
    # 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)
Abdallah committed
99
    hasher.update(unicode(user.id))
100 101
    if course_id:
        hasher.update(course_id.to_deprecated_string())
102
    digest = hasher.hexdigest()
103

104 105 106 107 108 109 110 111
    if not hasattr(user, '_anonymous_id'):
        user._anonymous_id = {}  # pylint: disable=protected-access

    user._anonymous_id[course_id] = digest  # pylint: disable=protected-access

    if save is False:
        return digest

112
    try:
113
        anonymous_user_id, __ = AnonymousUserId.objects.get_or_create(
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
            defaults={'anonymous_user_id': digest},
            user=user,
            course_id=course_id
        )
        if anonymous_user_id.anonymous_user_id != digest:
            log.error(
                "Stored anonymous user id {stored!r} for user {user!r} "
                "in course {course!r} doesn't match computed id {digest!r}".format(
                    user=user,
                    course=course_id,
                    stored=anonymous_user_id.anonymous_user_id,
                    digest=digest
                )
            )
    except IntegrityError:
        # Another thread has already created this entry, so
        # continue
        pass

    return digest
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153


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


Adam Palay committed
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
class UserStanding(models.Model):
    """
    This table contains a student's account's status.
    Currently, we're only disabling accounts; in the future we can imagine
    taking away more specific privileges, like forums access, or adding
    more specific karma levels or probationary stages.
    """
    ACCOUNT_DISABLED = "disabled"
    ACCOUNT_ENABLED = "enabled"
    USER_STANDING_CHOICES = (
        (ACCOUNT_DISABLED, u"Account Disabled"),
        (ACCOUNT_ENABLED, u"Account Enabled"),
    )

    user = models.ForeignKey(User, db_index=True, related_name='standing', unique=True)
    account_status = models.CharField(
        blank=True, max_length=31, choices=USER_STANDING_CHOICES
    )
    changed_by = models.ForeignKey(User, blank=True)
    standing_last_changed_at = models.DateTimeField(auto_now=True)

ichuang committed
175

176
class UserProfile(models.Model):
177
    """This is where we store all the user demographic fields. We have a
178 179 180
    separate table for this rather than extending the built-in Django auth_user.

    Notes:
181
        * Some fields are legacy ones from the first run of 6.002, from which
182 183 184 185 186 187 188 189 190 191 192 193 194
          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.
    """

195 196 197
    class Meta:
        db_table = "auth_userprofile"

198
    # CRITICAL TODO/SECURITY
199
    # Sanitize all fields.
200
    # This is not visible to other users, but could introduce holes later
201
    user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile')
202
    name = models.CharField(blank=True, max_length=255, db_index=True)
203

204
    meta = models.TextField(blank=True)  # JSON dictionary for future expansion
205
    courseware = models.CharField(blank=True, max_length=255, default='course.xml')
206 207 208 209 210 211 212

    # 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
213
    this_year = datetime.now(UTC).year
214
    VALID_YEARS = range(this_year, this_year - 120, -1)
215
    year_of_birth = models.IntegerField(blank=True, null=True, db_index=True)
216 217 218
    GENDER_CHOICES = (
        ('m', ugettext_noop('Male')),
        ('f', ugettext_noop('Female')),
219
        # Translators: 'Other' refers to the student's gender
220 221
        ('o', ugettext_noop('Other'))
    )
222 223 224
    gender = models.CharField(
        blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES
    )
225 226 227 228 229

    # [03/21/2013] removed these, but leaving comment since there'll still be
    # p_se and p_oth in the existing data in db.
    # ('p_se', 'Doctorate in science or engineering'),
    # ('p_oth', 'Doctorate in another field'),
230
    LEVEL_OF_EDUCATION_CHOICES = (
231 232 233 234 235 236 237
        ('p', ugettext_noop('Doctorate')),
        ('m', ugettext_noop("Master's or professional degree")),
        ('b', ugettext_noop("Bachelor's degree")),
        ('a', ugettext_noop("Associate's degree")),
        ('hs', ugettext_noop("Secondary/high school")),
        ('jhs', ugettext_noop("Junior secondary/junior high/middle school")),
        ('el', ugettext_noop("Elementary/primary school")),
238
        # Translators: 'None' refers to the student's level of education
239
        ('none', ugettext_noop("None")),
240
        # Translators: 'Other' refers to the student's level of education
241
        ('other', ugettext_noop("Other"))
242
    )
243
    level_of_education = models.CharField(
244 245 246
        blank=True, null=True, max_length=6, db_index=True,
        choices=LEVEL_OF_EDUCATION_CHOICES
    )
247
    mailing_address = models.TextField(blank=True, null=True)
248 249
    city = models.TextField(blank=True, null=True)
    country = CountryField(blank=True, null=True)
250
    goals = models.TextField(blank=True, null=True)
251
    allow_certificate = models.BooleanField(default=1)
252

253
    def get_meta(self):
254
        js_str = self.meta
255
        if not js_str:
256
            js_str = dict()
257
        else:
258
            js_str = json.loads(self.meta)
259

260
        return js_str
261

262
    def set_meta(self, js):
263 264
        self.meta = json.dumps(js)

265 266 267 268 269 270 271 272 273 274 275 276 277 278
    def set_login_session(self, session_id=None):
        """
        Sets the current session id for the logged-in user.
        If session_id doesn't match the existing session,
        deletes the old session object.
        """
        meta = self.get_meta()
        old_login = meta.get('session_id', None)
        if old_login:
            SessionStore(session_key=old_login).delete()
        meta['session_id'] = session_id
        self.set_meta(meta)
        self.save()

Calen Pennington committed
279

280 281 282 283 284
class UserSignupSource(models.Model):
    """
    This table contains information about users registering
    via Micro-Sites
    """
285
    user = models.ForeignKey(User, db_index=True)
286 287 288
    site = models.CharField(max_length=255, db_index=True)


289
def unique_id_for_user(user, save=True):
290 291 292
    """
    Return a unique id for a user, suitable for inserting into
    e.g. personalized survey links.
293 294 295

    Keyword arguments:
    save -- Whether the id should be saved in an AnonymousUserId object.
296
    """
297 298
    # Setting course_id to '' makes it not affect the generated hash,
    # and thus produce the old per-student anonymous id
299
    return anonymous_id_for_user(user, None, save=save)
300 301


302
# TODO: Should be renamed to generic UserGroup, and possibly
Piotr Mitros committed
303
# Given an optional field for type of group
Piotr Mitros committed
304 305 306 307
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)
308

309

310 311
class Registration(models.Model):
    ''' Allows us to wait for e-mail before user is registered. A
312
        registration profile is created when the user creates an
313 314 315 316 317 318 319 320 321 322
        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
323 324
        self.activation_key = uuid.uuid4().hex
        self.user = user
325 326 327 328 329 330
        self.save()

    def activate(self):
        self.user.is_active = True
        self.user.save()

331

332
class PendingNameChange(models.Model):
Piotr Mitros committed
333 334 335
    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)
336

337

338
class PendingEmailChange(models.Model):
Piotr Mitros committed
339
    user = models.OneToOneField(User, unique=True, db_index=True)
340
    new_email = models.CharField(blank=True, max_length=255, db_index=True)
341
    activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
342

ichuang committed
343

Gabe Mulley committed
344 345
EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated'
EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
346
EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed'
Gabe Mulley committed
347 348


349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488
class PasswordHistory(models.Model):
    """
    This model will keep track of past passwords that a user has used
    as well as providing contraints (e.g. can't reuse passwords)
    """
    user = models.ForeignKey(User)
    password = models.CharField(max_length=128)
    time_set = models.DateTimeField(default=timezone.now)

    def create(self, user):
        """
        This will copy over the current password, if any of the configuration has been turned on
        """

        if not (PasswordHistory.is_student_password_reuse_restricted() or
                PasswordHistory.is_staff_password_reuse_restricted() or
                PasswordHistory.is_password_reset_frequency_restricted() or
                PasswordHistory.is_staff_forced_password_reset_enabled() or
                PasswordHistory.is_student_forced_password_reset_enabled()):

            return

        self.user = user
        self.password = user.password
        self.save()

    @classmethod
    def is_student_password_reuse_restricted(cls):
        """
        Returns whether the configuration which limits password reuse has been turned on
        """
        return settings.FEATURES['ADVANCED_SECURITY'] and \
            settings.ADVANCED_SECURITY_CONFIG.get(
                'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE', 0
            ) > 0

    @classmethod
    def is_staff_password_reuse_restricted(cls):
        """
        Returns whether the configuration which limits password reuse has been turned on
        """
        return settings.FEATURES['ADVANCED_SECURITY'] and \
            settings.ADVANCED_SECURITY_CONFIG.get(
                'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE', 0
            ) > 0

    @classmethod
    def is_password_reset_frequency_restricted(cls):
        """
        Returns whether the configuration which limits the password reset frequency has been turned on
        """
        return settings.FEATURES['ADVANCED_SECURITY'] and \
            settings.ADVANCED_SECURITY_CONFIG.get(
                'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS', None
            )

    @classmethod
    def is_staff_forced_password_reset_enabled(cls):
        """
        Returns whether the configuration which forces password resets to occur has been turned on
        """
        return settings.FEATURES['ADVANCED_SECURITY'] and \
            settings.ADVANCED_SECURITY_CONFIG.get(
                'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS', None
            )

    @classmethod
    def is_student_forced_password_reset_enabled(cls):
        """
        Returns whether the configuration which forces password resets to occur has been turned on
        """
        return settings.FEATURES['ADVANCED_SECURITY'] and \
            settings.ADVANCED_SECURITY_CONFIG.get(
                'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS', None
            )

    @classmethod
    def should_user_reset_password_now(cls, user):
        """
        Returns whether a password has 'expired' and should be reset. Note there are two different
        expiry policies for staff and students
        """
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return False

        days_before_password_reset = None
        if user.is_staff:
            if cls.is_staff_forced_password_reset_enabled():
                days_before_password_reset = \
                    settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS']
        elif cls.is_student_forced_password_reset_enabled():
            days_before_password_reset = \
                settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS']

        if days_before_password_reset:
            history = PasswordHistory.objects.filter(user=user).order_by('-time_set')
            time_last_reset = None

            if history:
                # first element should be the last time we reset password
                time_last_reset = history[0].time_set
            else:
                # no history, then let's take the date the user joined
                time_last_reset = user.date_joined

            now = timezone.now()

            delta = now - time_last_reset

            return delta.days >= days_before_password_reset

        return False

    @classmethod
    def is_password_reset_too_soon(cls, user):
        """
        Verifies that the password is not getting reset too frequently
        """
        if not cls.is_password_reset_frequency_restricted():
            return False

        history = PasswordHistory.objects.filter(user=user).order_by('-time_set')

        if not history:
            return False

        now = timezone.now()

        delta = now - history[0].time_set

        return delta.days < settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS']

    @classmethod
    def is_allowable_password_reuse(cls, user, new_password):
        """
        Verifies that the password adheres to the reuse policies
        """
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return True

489
        if user.is_staff and cls.is_staff_password_reuse_restricted():
490 491 492 493 494
                min_diff_passwords_required = \
                    settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE']
        elif cls.is_student_password_reuse_restricted():
            min_diff_passwords_required = \
                settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE']
495 496
        else:
            min_diff_passwords_required = 0
497

498 499 500
        # just limit the result set to the number of different
        # password we need
        history = PasswordHistory.objects.filter(user=user).order_by('-time_set')[:min_diff_passwords_required]
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515

        for entry in history:

            # be sure to re-use the same salt
            # NOTE, how the salt is serialized in the password field is dependent on the algorithm
            # in pbkdf2_sha256 [LMS] it's the 3rd element, in sha1 [unit tests] it's the 2nd element
            hash_elements = entry.password.split('$')
            algorithm = hash_elements[0]
            if algorithm == 'pbkdf2_sha256':
                hashed_password = make_password(new_password, hash_elements[2])
            elif algorithm == 'sha1':
                hashed_password = make_password(new_password, hash_elements[1])
            else:
                # This means we got something unexpected. We don't want to throw an exception, but
                # log as an error and basically allow any password reuse
516 517 518 519
                AUDIT_LOG.error('''
                                Unknown password hashing algorithm "{0}" found in existing password
                                hash, password reuse policy will not be enforced!!!
                                '''.format(algorithm))
520 521
                return True

522
            if entry.password == hashed_password:
523 524 525 526 527
                return False

        return True


528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
class LoginFailures(models.Model):
    """
    This model will keep track of failed login attempts
    """
    user = models.ForeignKey(User)
    failure_count = models.IntegerField(default=0)
    lockout_until = models.DateTimeField(null=True)

    @classmethod
    def is_feature_enabled(cls):
        """
        Returns whether the feature flag around this functionality has been set
        """
        return settings.FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS']

    @classmethod
    def is_user_locked_out(cls, user):
        """
        Static method to return in a given user has his/her account locked out
        """
        try:
            record = LoginFailures.objects.get(user=user)
            if not record.lockout_until:
                return False

            now = datetime.now(UTC)
            until = record.lockout_until
            is_locked_out = until and now < until

            return is_locked_out
        except ObjectDoesNotExist:
            return False

    @classmethod
    def increment_lockout_counter(cls, user):
        """
        Ticks the failed attempt counter
        """
        record, _ = LoginFailures.objects.get_or_create(user=user)
        record.failure_count = record.failure_count + 1
        max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED

        # did we go over the limit in attempts
        if record.failure_count >= max_failures_allowed:
            # yes, then store when this account is locked out until
            lockout_period_secs = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS
            record.lockout_until = datetime.now(UTC) + timedelta(seconds=lockout_period_secs)

        record.save()

    @classmethod
    def clear_lockout_counter(cls, user):
        """
        Removes the lockout counters (normally called after a successful login)
        """
        try:
            entry = LoginFailures.objects.get(user=user)
            entry.delete()
        except ObjectDoesNotExist:
            return


590
class CourseEnrollment(models.Model):
591 592 593 594 595 596 597 598 599 600 601
    """
    Represents a Student's Enrollment record for a single Course. You should
    generally not manipulate CourseEnrollment objects directly, but use the
    classmethods provided to enroll, unenroll, or check on the enrollment status
    of a given student.

    We're starting to consolidate course enrollment logic in this class, but
    more should be brought in (such as checking against CourseEnrollmentAllowed,
    checking course dates, user permissions, etc.) This logic is currently
    scattered across our views.
    """
602 603
    MODEL_TAGS = ['course_id', 'is_active', 'mode']

604
    user = models.ForeignKey(User)
605
    course_id = CourseKeyField(max_length=255, db_index=True)
606
    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
607

608 609 610 611 612 613 614 615
    # If is_active is False, then the student is not considered to be enrolled
    # in the course (is_enrolled() will return False)
    is_active = models.BooleanField(default=True)

    # Represents the modes that are possible. We'll update this later with a
    # list of possible values.
    mode = models.CharField(default="honor", max_length=100)

616
    class Meta:
617
        unique_together = (('user', 'course_id'),)
618
        ordering = ('user', 'course_id')
619

620
    def __unicode__(self):
621 622 623 624 625
        return (
            "[CourseEnrollment] {}: {} ({}); active: ({})"
        ).format(self.user, self.course_id, self.created, self.is_active)

    @classmethod
626
    def get_or_create_enrollment(cls, user, course_key):
627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647
        """
        Create an enrollment for a user in a class. By default *this enrollment
        is not active*. This is useful for when an enrollment needs to go
        through some sort of approval process before being activated. If you
        don't need this functionality, just call `enroll()` instead.

        Returns a CoursewareEnrollment object.

        `user` is a Django User object. If it hasn't been saved yet (no `.id`
               attribute), this method will automatically save it before
               adding an enrollment for it.

        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)

        It is expected that this method is called from a method which has already
        verified the user authentication and access.
        """
        # If we're passing in a newly constructed (i.e. not yet persisted) User,
        # save it to the database so that it can have an ID that we can throw
        # into our CourseEnrollment object. Otherwise, we'll get an
        # IntegrityError for having a null user_id.
648 649
        assert(isinstance(course_key, CourseKey))

650 651 652
        if user.id is None:
            user.save()

Gabe Mulley committed
653
        enrollment, created = CourseEnrollment.objects.get_or_create(
654
            user=user,
655
            course_id=course_key,
656
        )
Gabe Mulley committed
657

Julia Hansbrough committed
658 659 660 661 662 663 664 665
        # If we *did* just create a new enrollment, set some defaults
        if created:
            enrollment.mode = "honor"
            enrollment.is_active = False
            enrollment.save()

        return enrollment

666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684
    @classmethod
    def num_enrolled_in(cls, course_id):
        """
        Returns the count of active enrollments in a course.

        'course_id' is the course_id to return enrollments
        """
        enrollment_number = CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count()

        return enrollment_number

    @classmethod
    def is_course_full(cls, course):
        """
        Returns a boolean value regarding whether a course has already reached it's max enrollment
        capacity
        """
        is_course_full = False
        if course.max_student_enrollments_allowed is not None:
685
            is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed
686 687
        return is_course_full

Julia Hansbrough committed
688 689 690 691 692 693 694 695 696
    def update_enrollment(self, mode=None, is_active=None):
        """
        Updates an enrollment for a user in a class.  This includes options
        like changing the mode, toggling is_active True/False, etc.

        Also emits relevant events for analytics purposes.

        This saves immediately.
        """
Gabe Mulley committed
697
        activation_changed = False
Julia Hansbrough committed
698 699
        # if is_active is None, then the call to update_enrollment didn't specify
        # any value, so just leave is_active as it is
Julia Hansbrough committed
700 701
        if self.is_active != is_active and is_active is not None:
            self.is_active = is_active
Gabe Mulley committed
702 703 704
            activation_changed = True

        mode_changed = False
705
        # if mode is None, the call to update_enrollment didn't specify a new
Julia Hansbrough committed
706
        # mode, so leave as-is
Julia Hansbrough committed
707 708
        if self.mode != mode and mode is not None:
            self.mode = mode
Gabe Mulley committed
709 710 711
            mode_changed = True

        if activation_changed or mode_changed:
Julia Hansbrough committed
712
            self.save()
713

Julia Hansbrough committed
714 715 716
        if activation_changed:
            if self.is_active:
                self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED)
717 718 719

                dog_stats_api.increment(
                    "common.student.enrollment",
720 721
                    tags=[u"org:{}".format(self.course_id.org),
                          u"offering:{}".format(self.course_id.offering),
722 723 724
                          u"mode:{}".format(self.mode)]
                )

Julia Hansbrough committed
725 726
            else:
                unenroll_done.send(sender=None, course_enrollment=self)
727
                
Julia Hansbrough committed
728
                self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
729

730 731
                dog_stats_api.increment(
                    "common.student.unenrollment",
732 733
                    tags=[u"org:{}".format(self.course_id.org),
                          u"offering:{}".format(self.course_id.offering),
734 735
                          u"mode:{}".format(self.mode)]
                )
736 737 738 739
        if mode_changed:
            # the user's default mode is "honor" and disabled for a course
            # mode change events will only be emitted when the user's mode changes from this
            self.emit_event(EVENT_NAME_ENROLLMENT_MODE_CHANGED)
740

Gabe Mulley committed
741 742 743 744 745 746 747
    def emit_event(self, event_name):
        """
        Emits an event to explicitly track course enrollment and unenrollment.
        """

        try:
            context = contexts.course_context_from_course_id(self.course_id)
748
            assert(isinstance(self.course_id, CourseKey))
Gabe Mulley committed
749 750
            data = {
                'user_id': self.user.id,
751
                'course_id': self.course_id.to_deprecated_string(),
Gabe Mulley committed
752 753 754 755
                'mode': self.mode,
            }

            with tracker.get_tracker().context(event_name, context):
756
                tracker.emit(event_name, data)
757 758

                if settings.FEATURES.get('SEGMENT_IO_LMS') and settings.SEGMENT_IO_LMS_KEY:
759
                    tracking_context = tracker.get_tracker().resolve_context()
760 761 762 763 764 765 766
                    analytics.track(self.user_id, event_name, {
                        'category': 'conversion',
                        'label': self.course_id.to_deprecated_string(),
                        'org': self.course_id.org,
                        'course': self.course_id.course,
                        'run': self.course_id.run,
                        'mode': self.mode,
767 768 769 770
                    }, context={
                        'Google Analytics': {
                            'clientId': tracking_context.get('client_id')
                        }
771
                    })
772

Gabe Mulley committed
773 774 775 776
        except:  # pylint: disable=bare-except
            if event_name and self.course_id:
                log.exception('Unable to emit event %s for user %s and course %s', event_name, self.user.username, self.course_id)

777
    @classmethod
778
    def enroll(cls, user, course_key, mode="honor"):
779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796
        """
        Enroll a user in a course. This saves immediately.

        Returns a CoursewareEnrollment object.

        `user` is a Django User object. If it hasn't been saved yet (no `.id`
               attribute), this method will automatically save it before
               adding an enrollment for it.

        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)

        `mode` is a string specifying what kind of enrollment this is. The
               default is "honor", meaning honor certificate. Future options
               may include "audit", "verified_id", etc. Please don't use it
               until we have these mapped out.

        It is expected that this method is called from a method which has already
        verified the user authentication and access.
797 798

        Also emits relevant events for analytics purposes.
799
        """
800
        enrollment = cls.get_or_create_enrollment(user, course_key)
801
        enrollment.update_enrollment(is_active=True, mode=mode)
Julia Hansbrough committed
802
        return enrollment
803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855

    @classmethod
    def enroll_by_email(cls, email, course_id, mode="honor", ignore_errors=True):
        """
        Enroll a user in a course given their email. This saves immediately.

        Note that  enrolling by email is generally done in big batches and the
        error rate is high. For that reason, we supress User lookup errors by
        default.

        Returns a CoursewareEnrollment object. If the User does not exist and
        `ignore_errors` is set to `True`, it will return None.

        `email` Email address of the User to add to enroll in the course.

        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)

        `mode` is a string specifying what kind of enrollment this is. The
               default is "honor", meaning honor certificate. Future options
               may include "audit", "verified_id", etc. Please don't use it
               until we have these mapped out.

        `ignore_errors` is a boolean indicating whether we should suppress
                        `User.DoesNotExist` errors (returning None) or let it
                        bubble up.

        It is expected that this method is called from a method which has already
        verified the user authentication and access.
        """
        try:
            user = User.objects.get(email=email)
            return cls.enroll(user, course_id, mode)
        except User.DoesNotExist:
            err_msg = u"Tried to enroll email {} into course {}, but user not found"
            log.error(err_msg.format(email, course_id))
            if ignore_errors:
                return None
            raise

    @classmethod
    def unenroll(cls, user, course_id):
        """
        Remove the user from a given course. If the relevant `CourseEnrollment`
        object doesn't exist, we log an error but don't throw an exception.

        `user` is a Django User object. If it hasn't been saved yet (no `.id`
               attribute), this method will automatically save it before
               adding an enrollment for it.

        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
        """
        try:
            record = CourseEnrollment.objects.get(user=user, course_id=course_id)
Julia Hansbrough committed
856
            record.update_enrollment(is_active=False)
Gabe Mulley committed
857

858
        except cls.DoesNotExist:
859 860
            err_msg = u"Tried to unenroll student {} from {} but they were not enrolled"
            log.error(err_msg.format(user, course_id))
861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879

    @classmethod
    def unenroll_by_email(cls, email, course_id):
        """
        Unenroll a user from a course given their email. This saves immediately.
        User lookup errors are logged but will not throw an exception.

        `email` Email address of the User to unenroll from the course.

        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
        """
        try:
            user = User.objects.get(email=email)
            return cls.unenroll(user, course_id)
        except User.DoesNotExist:
            err_msg = u"Tried to unenroll email {} from course {}, but user not found"
            log.error(err_msg.format(email, course_id))

    @classmethod
880
    def is_enrolled(cls, user, course_key):
881 882 883 884 885 886 887 888 889 890 891
        """
        Returns True if the user is enrolled in the course (the entry must exist
        and it must have `is_active=True`). Otherwise, returns False.

        `user` is a Django User object. If it hasn't been saved yet (no `.id`
               attribute), this method will automatically save it before
               adding an enrollment for it.

        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
        """
        try:
892
            record = CourseEnrollment.objects.get(user=user, course_id=course_key)
893 894 895 896 897
            return record.is_active
        except cls.DoesNotExist:
            return False

    @classmethod
898 899 900 901 902 903 904 905 906 907 908 909
    def is_enrolled_by_partial(cls, user, course_id_partial):
        """
        Returns `True` if the user is enrolled in a course that starts with
        `course_id_partial`. Otherwise, returns False.

        Can be used to determine whether a student is enrolled in a course
        whose run name is unknown.

        `user` is a Django User object. If it hasn't been saved yet (no `.id`
               attribute), this method will automatically save it before
               adding an enrollment for it.

910
        `course_id_partial` (CourseKey) is missing the run component
911
        """
912
        assert isinstance(course_id_partial, CourseKey)
913 914 915
        assert not course_id_partial.run  # None or empty string
        course_key = SlashSeparatedCourseKey(course_id_partial.org, course_id_partial.course, '')
        querystring = unicode(course_key.to_deprecated_string())
916 917
        try:
            return CourseEnrollment.objects.filter(
Julian Arni committed
918
                user=user,
919
                course_id__startswith=querystring,
Julian Arni committed
920 921
                is_active=1
            ).exists()
922 923 924 925
        except cls.DoesNotExist:
            return False

    @classmethod
926 927 928 929 930 931
    def enrollment_mode_for_user(cls, user, course_id):
        """
        Returns the enrollment mode for the given user for the given course

        `user` is a Django User object
        `course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
932

933 934 935
        Returns (mode, is_active) where mode is the enrollment mode of the student
            and is_active is whether the enrollment is active.
        Returns (None, None) if the courseenrollment record does not exist.
936 937 938
        """
        try:
            record = CourseEnrollment.objects.get(user=user, course_id=course_id)
939
            return (record.mode, record.is_active)
940
        except cls.DoesNotExist:
941
            return (None, None)
942 943

    @classmethod
944 945 946
    def enrollments_for_user(cls, user):
        return CourseEnrollment.objects.filter(user=user, is_active=1)

947 948 949 950 951 952 953 954
    @classmethod
    def users_enrolled_in(cls, course_id):
        """Return a queryset of User for every user enrolled in the course."""
        return User.objects.filter(
            courseenrollment__course_id=course_id,
            courseenrollment__is_active=True
        )

Julia Hansbrough committed
955
    @classmethod
Julia Hansbrough committed
956
    def enrollment_counts(cls, course_id):
Julia Hansbrough committed
957
        """
Julia Hansbrough committed
958 959
        Returns a dictionary that stores the total enrollment count for a course, as well as the
        enrollment count for each individual mode.
Julia Hansbrough committed
960
        """
Julia Hansbrough committed
961
        # Unfortunately, Django's "group by"-style queries look super-awkward
Julia Hansbrough committed
962
        query = use_read_replica_if_available(cls.objects.filter(course_id=course_id, is_active=True).values('mode').order_by().annotate(Count('mode')))
Julia Hansbrough committed
963 964 965 966 967 968
        total = 0
        d = defaultdict(int)
        for item in query:
            d[item['mode']] = item['mode__count']
            total += item['mode__count']
        d['total'] = total
Julia Hansbrough committed
969
        return d
Julia Hansbrough committed
970

971 972 973 974 975 976
    def is_paid_course(self):
        """
        Returns True, if course is paid
        """
        paid_course = CourseMode.objects.filter(Q(course_id=self.course_id) & Q(mode_slug='honor') &
                                                (Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=datetime.now(pytz.UTC)))).exclude(min_price=0)
977
        if paid_course or self.mode == 'professional':
978 979 980 981
            return True

        return False

982 983
    def activate(self):
        """Makes this `CourseEnrollment` record active. Saves immediately."""
Julia Hansbrough committed
984
        self.update_enrollment(is_active=True)
985 986 987 988 989

    def deactivate(self):
        """Makes this `CourseEnrollment` record inactive. Saves immediately. An
        inactive record means that the student is not enrolled in this course.
        """
Julia Hansbrough committed
990
        self.update_enrollment(is_active=False)
991 992

    def change_mode(self, mode):
993
        """Changes this `CourseEnrollment` record's mode to `mode`.  Saves immediately."""
Julia Hansbrough committed
994
        self.update_enrollment(mode=mode)
ichuang committed
995

996 997
    def refundable(self):
        """
998
        For paid/verified certificates, students may receive a refund if they have
999 1000
        a verified certificate and the deadline for refunds has not yet passed.
        """
1001 1002 1003 1004 1005 1006 1007
        # In order to support manual refunds past the deadline, set can_refund on this object.
        # On unenrolling, the "unenroll_done" signal calls CertificateItem.refund_cert_callback(),
        # which calls this method to determine whether to refund the order.
        # This can't be set directly because refunds currently happen as a side-effect of unenrolling.
        # (side-effects are bad)
        if getattr(self, 'can_refund', None) is not None:
            return True
1008 1009 1010 1011 1012

        # If the student has already been given a certificate they should not be refunded
        if GeneratedCertificate.certificate_for_student(self.user, self.course_id) is not None:
            return False

1013 1014
        #TODO - When Course administrators to define a refund period for paid courses then refundable will be supported. # pylint: disable=W0511

1015 1016 1017 1018 1019 1020 1021
        course_mode = CourseMode.mode_for_course(self.course_id, 'verified')
        if course_mode is None:
            return False
        else:
            return True


1022 1023 1024 1025 1026 1027 1028
class CourseEnrollmentAllowed(models.Model):
    """
    Table of users (specified by email address strings) who are allowed to enroll in a specified course.
    The user may or may not (yet) exist.  Enrollment by users listed in this table is allowed
    even if the enrollment time window is past.
    """
    email = models.CharField(max_length=255, db_index=True)
1029
    course_id = CourseKeyField(max_length=255, db_index=True)
1030
    auto_enroll = models.BooleanField(default=0)
1031 1032 1033 1034

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

    class Meta:
1035
        unique_together = (('email', 'course_id'),)
1036

1037 1038
    def __unicode__(self):
        return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created)
1039

1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084

@total_ordering
class CourseAccessRole(models.Model):
    """
    Maps users to org, courses, and roles. Used by student.roles.CourseRole and OrgRole.
    To establish a user as having a specific role over all courses in the org, create an entry
    without a course_id.
    """

    objects = NoneToEmptyManager()

    user = models.ForeignKey(User)
    # blank org is for global group based roles such as course creator (may be deprecated)
    org = models.CharField(max_length=64, db_index=True, blank=True)
    # blank course_id implies org wide role
    course_id = CourseKeyField(max_length=255, db_index=True, blank=True)
    role = models.CharField(max_length=64, db_index=True)

    class Meta:
        unique_together = ('user', 'org', 'course_id', 'role')

    @property
    def _key(self):
        """
        convenience function to make eq overrides easier and clearer. arbitrary decision
        that role is primary, followed by org, course, and then user
        """
        return (self.role, self.org, self.course_id, self.user)

    def __eq__(self, other):
        """
        Overriding eq b/c the django impl relies on the primary key which requires fetch. sometimes we
        just want to compare roles w/o doing another fetch.
        """
        return type(self) == type(other) and self._key == other._key

    def __hash__(self):
        return hash(self._key)

    def __lt__(self, other):
        """
        Lexigraphic sort
        """
        return self._key < other._key

1085 1086 1087
    def __unicode__(self):
        return "[CourseAccessRole] user: {}   role: {}   org: {}   course: {}".format(self.user.username, self.role, self.org, self.course_id)

1088

1089 1090 1091
class CourseAccessRoleAdmin(admin.ModelAdmin):
    raw_id_fields = ("user",)

1092 1093
#### Helper methods for use from python manage.py shell and other classes.

Calen Pennington committed
1094

1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106
def get_user_by_username_or_email(username_or_email):
    """
    Return a User object, looking up by email if username_or_email contains a
    '@', otherwise by username.

    Raises:
        User.DoesNotExist is lookup fails.
    """
    if '@' in username_or_email:
        return User.objects.get(email=username_or_email)
    else:
        return User.objects.get(username=username_or_email)
1107

1108

1109
def get_user(email):
1110 1111 1112 1113
    u = User.objects.get(email=email)
    up = UserProfile.objects.get(user=u)
    return u, up

1114 1115

def user_info(email):
1116
    u, up = get_user(email)
1117 1118 1119 1120 1121 1122
    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
1123 1124
    return u, up

1125 1126

def change_email(old_email, new_email):
1127
    u = User.objects.get(email=old_email)
1128 1129 1130
    u.email = new_email
    u.save()

1131

1132
def change_name(email, new_name):
1133
    u, up = get_user(email)
1134 1135 1136
    up.name = new_name
    up.save()

1137

Piotr Mitros committed
1138
def user_count():
Piotr Mitros committed
1139
    print "All users", User.objects.all().count()
1140
    print "Active users", User.objects.filter(is_active=True).count()
Piotr Mitros committed
1141 1142
    return User.objects.all().count()

1143

Piotr Mitros committed
1144
def active_user_count():
1145 1146
    return User.objects.filter(is_active=True).count()

Piotr Mitros committed
1147

Piotr Mitros committed
1148 1149 1150 1151 1152 1153
def create_group(name, description):
    utg = UserTestGroup()
    utg.name = name
    utg.description = description
    utg.save()

1154

1155
def add_user_to_group(user, group):
1156 1157
    utg = UserTestGroup.objects.get(name=group)
    utg.users.add(User.objects.get(username=user))
Piotr Mitros committed
1158
    utg.save()
1159

1160

1161
def remove_user_from_group(user, group):
1162 1163
    utg = UserTestGroup.objects.get(name=group)
    utg.users.remove(User.objects.get(username=user))
1164
    utg.save()
Piotr Mitros committed
1165

1166 1167 1168 1169 1170
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'}

1171 1172 1173

def add_user_to_default_group(user, group):
    try:
1174
        utg = UserTestGroup.objects.get(name=group)
1175
    except UserTestGroup.DoesNotExist:
1176 1177 1178 1179
        utg = UserTestGroup()
        utg.name = group
        utg.description = default_groups[group]
        utg.save()
1180
    utg.users.add(User.objects.get(username=user))
Piotr Mitros committed
1181
    utg.save()
Rocky Duan committed
1182

ichuang committed
1183

1184
def create_comments_service_user(user):
1185
    if not settings.FEATURES['ENABLE_DISCUSSION_SERVICE']:
1186 1187
        # Don't try--it won't work, and it will fill the logs with lots of errors
        return
Rocky Duan committed
1188
    try:
1189
        cc_user = cc.User.from_django_user(user)
1190
        cc_user.save()
Rocky Duan committed
1191
    except Exception as e:
1192
        log = logging.getLogger("edx.discussion")
1193 1194 1195
        log.error(
            "Could not create comments service user with id {}".format(user.id),
            exc_info=True)
1196 1197 1198 1199 1200 1201 1202 1203 1204 1205

# Define login and logout handlers here in the models file, instead of the views file,
# so that they are more likely to be loaded when a Studio user brings up the Studio admin
# page to login.  These are currently the only signals available, so we need to continue
# identifying and logging failures separately (in views).


@receiver(user_logged_in)
def log_successful_login(sender, request, user, **kwargs):
    """Handler to log when logins have occurred successfully."""
1206 1207 1208 1209
    if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
        AUDIT_LOG.info(u"Login success - user.id: {0}".format(user.id))
    else:
        AUDIT_LOG.info(u"Login success - {0} ({1})".format(user.username, user.email))
1210 1211 1212 1213 1214


@receiver(user_logged_out)
def log_successful_logout(sender, request, user, **kwargs):
    """Handler to log when logouts have occurred successfully."""
1215 1216 1217 1218
    if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
        AUDIT_LOG.info(u"Logout - user.id: {0}".format(request.user.id))
    else:
        AUDIT_LOG.info(u"Logout - {0}".format(request.user))
1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233


@receiver(user_logged_in)
@receiver(user_logged_out)
def enforce_single_login(sender, request, user, signal, **kwargs):
    """
    Sets the current session id in the user profile,
    to prevent concurrent logins.
    """
    if settings.FEATURES.get('PREVENT_CONCURRENT_LOGINS', False):
        if signal == user_logged_in:
            key = request.session.session_key
        else:
            key = None
        user.profile.set_login_session(key)