models.py 88.6 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 14 15 16
import hashlib
import json
import logging
import uuid
17
from collections import defaultdict, OrderedDict, namedtuple
18
from datetime import datetime, timedelta
19 20
from functools import total_ordering
from importlib import import_module
21
from urllib import urlencode
22

23
import analytics
24
import dogstats_wrapper as dog_stats_api
25
from config_models.models import ConfigurationModel
26
from django.conf import settings
27
from django.contrib.auth.hashers import make_password
28
from django.contrib.auth.models import User
29
from django.contrib.auth.signals import user_logged_in, user_logged_out
30 31
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
32
from django.db import models, IntegrityError
Julia Hansbrough committed
33
from django.db.models import Count
34
from django.db.models.signals import pre_save, post_save
Jay Zoldak committed
35
from django.dispatch import receiver, Signal
36 37
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
38
from django.utils.translation import ugettext_noop
39
from django_countries.fields import CountryField
Gabe Mulley committed
40
from eventtracking import tracker
41
from model_utils.models import TimeStampedModel
42
from opaque_keys.edx.keys import CourseKey
43
from opaque_keys.edx.locations import SlashSeparatedCourseKey
44
from pytz import UTC
45
from simple_history.models import HistoricalRecords
Julia Hansbrough committed
46

47 48
import lms.lib.comment_client as cc
import request_cache
49 50
from certificates.models import GeneratedCertificate
from course_modes.models import CourseMode
51
from enrollment.api import _default_course_mode
52
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
53
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
54 55 56
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, NoneToEmptyManager
from track import contexts
from util.milestones_helpers import is_entrance_exams_enabled
57 58
from util.model_utils import emit_field_changed_events, get_changed_fields_dict
from util.query import use_read_replica_if_available
59

60
UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"])
61
ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"])
62
log = logging.getLogger(__name__)
63
AUDIT_LOG = logging.getLogger("audit")
Sarina Canelake committed
64
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore  # pylint: disable=invalid-name
65

66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
# enroll status changed events - signaled to email_marketing.  See email_marketing.tasks for more info


# ENROLL signal used for free enrollment only
class EnrollStatusChange(object):
    """
    Possible event types for ENROLL_STATUS_CHANGE signal
    """
    # enroll for a course
    enroll = 'enroll'
    # unenroll for a course
    unenroll = 'unenroll'
    # add an upgrade to cart
    upgrade_start = 'upgrade_start'
    # complete an upgrade purchase
    upgrade_complete = 'upgrade_complete'
    # add a paid course to the cart
    paid_start = 'paid_start'
    # complete a paid course purchase
    paid_complete = 'paid_complete'

87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
UNENROLLED_TO_ALLOWEDTOENROLL = 'from unenrolled to allowed to enroll'
ALLOWEDTOENROLL_TO_ENROLLED = 'from allowed to enroll to enrolled'
ENROLLED_TO_ENROLLED = 'from enrolled to enrolled'
ENROLLED_TO_UNENROLLED = 'from enrolled to unenrolled'
UNENROLLED_TO_ENROLLED = 'from unenrolled to enrolled'
ALLOWEDTOENROLL_TO_UNENROLLED = 'from allowed to enroll to enrolled'
UNENROLLED_TO_UNENROLLED = 'from unenrolled to unenrolled'
DEFAULT_TRANSITION_STATE = 'N/A'

TRANSITION_STATES = (
    (UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ALLOWEDTOENROLL),
    (ALLOWEDTOENROLL_TO_ENROLLED, ALLOWEDTOENROLL_TO_ENROLLED),
    (ENROLLED_TO_ENROLLED, ENROLLED_TO_ENROLLED),
    (ENROLLED_TO_UNENROLLED, ENROLLED_TO_UNENROLLED),
    (UNENROLLED_TO_ENROLLED, UNENROLLED_TO_ENROLLED),
    (ALLOWEDTOENROLL_TO_UNENROLLED, ALLOWEDTOENROLL_TO_UNENROLLED),
    (UNENROLLED_TO_UNENROLLED, UNENROLLED_TO_UNENROLLED),
    (DEFAULT_TRANSITION_STATE, DEFAULT_TRANSITION_STATE)
)

107

108 109 110 111 112 113
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.

114 115
    We generate anonymous_user_id using md5 algorithm,
    and use result in hex form, so its length is equal to 32 bytes.
116
    """
117 118 119

    objects = NoneToEmptyManager()

120
    user = models.ForeignKey(User, db_index=True)
121
    anonymous_user_id = models.CharField(unique=True, max_length=32)
122
    course_id = CourseKeyField(db_index=True, max_length=255, blank=True)
123 124


125
def anonymous_id_for_user(user, course_id, save=True):
126 127 128 129 130
    """
    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`
131 132 133

    Keyword arguments:
    save -- Whether the id should be saved in an AnonymousUserId object.
134 135 136 137 138
    """
    # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated.
    if user.is_anonymous():
        return None

139 140 141 142
    cached_id = getattr(user, '_anonymous_id', {}).get(course_id)
    if cached_id is not None:
        return cached_id

143 144 145
    # 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
146
    hasher.update(unicode(user.id))
147
    if course_id:
148
        hasher.update(unicode(course_id).encode('utf-8'))
149
    digest = hasher.hexdigest()
150

151 152 153 154 155 156 157 158
    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

159
    try:
160
        AnonymousUserId.objects.get_or_create(
161
            user=user,
162 163
            course_id=course_id,
            anonymous_user_id=digest,
164 165 166 167 168 169 170
        )
    except IntegrityError:
        # Another thread has already created this entry, so
        # continue
        pass

    return digest
171 172


Sarina Canelake committed
173
def user_by_anonymous_id(uid):
174 175 176 177 178 179 180 181
    """
    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.
    """

Sarina Canelake committed
182
    if uid is None:
183 184 185
        return None

    try:
Sarina Canelake committed
186
        return User.objects.get(anonymoususerid__anonymous_user_id=uid)
187 188 189 190
    except ObjectDoesNotExist:
        return None


Adam Palay committed
191 192 193 194 195 196 197 198 199 200 201 202 203 204
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"),
    )

205
    user = models.OneToOneField(User, db_index=True, related_name='standing')
Adam Palay committed
206 207 208 209 210 211
    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
212

213
class UserProfile(models.Model):
214
    """This is where we store all the user demographic fields. We have a
215 216 217
    separate table for this rather than extending the built-in Django auth_user.

    Notes:
218
        * Some fields are legacy ones from the first run of 6.002, from which
219 220 221 222 223 224 225 226 227 228 229 230
          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.
    """
231 232
    # cache key format e.g user.<user_id>.profile.country = 'SG'
    PROFILE_COUNTRY_CACHE_KEY = u"user.{user_id}.profile.country"
233

234
    class Meta(object):
235
        db_table = "auth_userprofile"
236
        permissions = (("can_deactivate_users", "Can deactivate, but NOT delete users"),)
237

238
    # CRITICAL TODO/SECURITY
239
    # Sanitize all fields.
240
    # This is not visible to other users, but could introduce holes later
241
    user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile')
242
    name = models.CharField(blank=True, max_length=255, db_index=True)
243

244
    meta = models.TextField(blank=True)  # JSON dictionary for future expansion
245
    courseware = models.CharField(blank=True, max_length=255, default='course.xml')
246 247 248 249 250 251 252

    # 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
253
    this_year = datetime.now(UTC).year
254
    VALID_YEARS = range(this_year, this_year - 120, -1)
255
    year_of_birth = models.IntegerField(blank=True, null=True, db_index=True)
256 257 258
    GENDER_CHOICES = (
        ('m', ugettext_noop('Male')),
        ('f', ugettext_noop('Female')),
259
        # Translators: 'Other' refers to the student's gender
260
        ('o', ugettext_noop('Other/Prefer Not to Say'))
261
    )
262 263 264
    gender = models.CharField(
        blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES
    )
265 266 267 268 269

    # [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'),
270
    LEVEL_OF_EDUCATION_CHOICES = (
271 272 273
        ('p', ugettext_noop('Doctorate')),
        ('m', ugettext_noop("Master's or professional degree")),
        ('b', ugettext_noop("Bachelor's degree")),
274
        ('a', ugettext_noop("Associate degree")),
275 276 277
        ('hs', ugettext_noop("Secondary/high school")),
        ('jhs', ugettext_noop("Junior secondary/junior high/middle school")),
        ('el', ugettext_noop("Elementary/primary school")),
278
        # Translators: 'None' refers to the student's level of education
279
        ('none', ugettext_noop("No formal education")),
280
        # Translators: 'Other' refers to the student's level of education
281
        ('other', ugettext_noop("Other education"))
282
    )
283
    level_of_education = models.CharField(
284 285 286
        blank=True, null=True, max_length=6, db_index=True,
        choices=LEVEL_OF_EDUCATION_CHOICES
    )
287
    mailing_address = models.TextField(blank=True, null=True)
288 289
    city = models.TextField(blank=True, null=True)
    country = CountryField(blank=True, null=True)
290
    goals = models.TextField(blank=True, null=True)
291
    allow_certificate = models.BooleanField(default=1)
292
    bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False)
293
    profile_image_uploaded_at = models.DateTimeField(null=True, blank=True)
294 295 296 297 298 299 300 301

    @property
    def has_profile_image(self):
        """
        Convenience method that returns a boolean indicating whether or not
        this user has uploaded a profile image.
        """
        return self.profile_image_uploaded_at is not None
302

303 304 305 306 307 308
    @property
    def age(self):
        """ Convenience method that returns the age given a year_of_birth. """
        year_of_birth = self.year_of_birth
        year = datetime.now(UTC).year
        if year_of_birth is not None:
309
            return self._calculate_age(year, year_of_birth)
310 311 312 313 314 315 316 317 318 319 320 321 322

    @property
    def level_of_education_display(self):
        """ Convenience method that returns the human readable level of education. """
        if self.level_of_education:
            return self.__enumerable_to_display(self.LEVEL_OF_EDUCATION_CHOICES, self.level_of_education)

    @property
    def gender_display(self):
        """ Convenience method that returns the human readable gender. """
        if self.gender:
            return self.__enumerable_to_display(self.GENDER_CHOICES, self.gender)

Sarina Canelake committed
323
    def get_meta(self):  # pylint: disable=missing-docstring
324
        js_str = self.meta
325
        if not js_str:
326
            js_str = dict()
327
        else:
328
            js_str = json.loads(self.meta)
329

330
        return js_str
331

Sarina Canelake committed
332 333
    def set_meta(self, meta_json):  # pylint: disable=missing-docstring
        self.meta = json.dumps(meta_json)
334

335 336 337 338 339 340 341 342 343 344 345 346 347 348
    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()

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
    def requires_parental_consent(self, date=None, age_limit=None, default_requires_consent=True):
        """Returns true if this user requires parental consent.

        Args:
            date (Date): The date for which consent needs to be tested (defaults to now).
            age_limit (int): The age limit at which parental consent is no longer required.
                This defaults to the value of the setting 'PARENTAL_CONTROL_AGE_LIMIT'.
            default_requires_consent (bool): True if users require parental consent if they
                have no specified year of birth (default is True).

        Returns:
             True if the user requires parental consent.
        """
        if age_limit is None:
            age_limit = getattr(settings, 'PARENTAL_CONSENT_AGE_LIMIT', None)
            if age_limit is None:
                return False

        # Return True if either:
        # a) The user has a year of birth specified and that year is fewer years in the past than the limit.
        # b) The user has no year of birth specified and the default is to require consent.
        #
        # Note: we have to be conservative using the user's year of birth as their birth date could be
        # December 31st. This means that if the number of years since their birth year is exactly equal
        # to the age limit then we have to assume that they might still not be old enough.
        year_of_birth = self.year_of_birth
        if year_of_birth is None:
            return default_requires_consent
377

378
        if date is None:
379 380
            age = self.age
        else:
381
            age = self._calculate_age(date.year, year_of_birth)
382

383
        return age < age_limit
384 385 386 387

    def __enumerable_to_display(self, enumerables, enum_value):
        """ Get the human readable value from an enumerable list of key-value pairs. """
        return dict(enumerables)[enum_value]
388

389 390 391 392 393 394 395 396 397 398 399
    def _calculate_age(self, year, year_of_birth):
        """Calculate the youngest age for a user with a given year of birth.

        :param year: year
        :param year_of_birth: year of birth
        :return: youngest age a user could be for the given year
        """
        # There are legal implications regarding how we can contact users and what information we can make public
        # based on their age, so we must take the most conservative estimate.
        return year - year_of_birth - 1

400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422
    @classmethod
    def country_cache_key_name(cls, user_id):
        """Return cache key name to be used to cache current country.
        Args:
            user_id(int): Id of user.

        Returns:
            Unicode cache key
        """
        return cls.PROFILE_COUNTRY_CACHE_KEY.format(user_id=user_id)


@receiver(models.signals.post_save, sender=UserProfile)
def invalidate_user_profile_country_cache(sender, instance, **kwargs):  # pylint:   disable=unused-argument, invalid-name
    """Invalidate the cache of country in UserProfile model. """

    changed_fields = getattr(instance, '_changed_fields', {})

    if 'country' in changed_fields:
        cache_key = UserProfile.country_cache_key_name(instance.user_id)
        cache.delete(cache_key)
        log.info("Country changed in UserProfile for %s, cache deleted", instance.user_id)

423 424 425 426 427 428 429 430 431 432

@receiver(pre_save, sender=UserProfile)
def user_profile_pre_save_callback(sender, **kwargs):
    """
    Ensure consistency of a user profile before saving it.
    """
    user_profile = kwargs['instance']

    # Remove profile images for users who require parental consent
    if user_profile.requires_parental_consent() and user_profile.has_profile_image:
433
        user_profile.profile_image_uploaded_at = None
434

435 436 437 438 439
    # Cache "old" field values on the model instance so that they can be
    # retrieved in the post_save callback when we emit an event with new and
    # old field values.
    user_profile._changed_fields = get_changed_fields_dict(user_profile, sender)

Calen Pennington committed
440

441 442 443 444 445 446 447 448
@receiver(post_save, sender=UserProfile)
def user_profile_post_save_callback(sender, **kwargs):
    """
    Emit analytics events after saving the UserProfile.
    """
    user_profile = kwargs['instance']
    # pylint: disable=protected-access
    emit_field_changed_events(
449 450 451 452
        user_profile,
        user_profile.user,
        sender._meta.db_table,
        excluded_fields=['meta']
453 454 455
    )


456 457 458 459 460 461 462 463
@receiver(pre_save, sender=User)
def user_pre_save_callback(sender, **kwargs):
    """
    Capture old fields on the user instance before save and cache them as a
    private field on the current model for use in the post_save callback.
    """
    user = kwargs['instance']
    user._changed_fields = get_changed_fields_dict(user, sender)
464 465


466 467
@receiver(post_save, sender=User)
def user_post_save_callback(sender, **kwargs):
468
    """
469 470 471 472 473 474 475 476
    Emit analytics events after saving the User.
    """
    user = kwargs['instance']
    # pylint: disable=protected-access
    emit_field_changed_events(
        user,
        user,
        sender._meta.db_table,
477
        excluded_fields=['last_login', 'first_name', 'last_name'],
478 479
        hidden_fields=['password']
    )
480 481


482 483 484 485 486
class UserSignupSource(models.Model):
    """
    This table contains information about users registering
    via Micro-Sites
    """
487
    user = models.ForeignKey(User, db_index=True)
488 489 490
    site = models.CharField(max_length=255, db_index=True)


491
def unique_id_for_user(user, save=True):
492 493 494
    """
    Return a unique id for a user, suitable for inserting into
    e.g. personalized survey links.
495 496 497

    Keyword arguments:
    save -- Whether the id should be saved in an AnonymousUserId object.
498
    """
499 500
    # Setting course_id to '' makes it not affect the generated hash,
    # and thus produce the old per-student anonymous id
501
    return anonymous_id_for_user(user, None, save=save)
502 503


504
# TODO: Should be renamed to generic UserGroup, and possibly
Piotr Mitros committed
505
# Given an optional field for type of group
Piotr Mitros committed
506 507 508 509
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)
510

511

512 513
class Registration(models.Model):
    ''' Allows us to wait for e-mail before user is registered. A
514
        registration profile is created when the user creates an
515 516
        account, but that account is inactive. Once the user clicks
        on the activation key, it becomes active. '''
517 518

    class Meta(object):
519 520
        db_table = "auth_registration"

521
    user = models.OneToOneField(User)
522 523 524 525
    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
526 527
        self.activation_key = uuid.uuid4().hex
        self.user = user
528 529 530 531
        self.save()

    def activate(self):
        self.user.is_active = True
532
        self._track_activation()
533
        self.user.save()
534
        log.info(u'User %s (%s) account is successfully activated.', self.user.username, self.user.email)
535

536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555
    def _track_activation(self):
        """ Update the isActive flag in mailchimp for activated users."""
        has_segment_key = getattr(settings, 'LMS_SEGMENT_KEY', None)
        has_mailchimp_id = hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID')
        if has_segment_key and has_mailchimp_id:
            identity_args = [
                self.user.id,  # pylint: disable=no-member
                {
                    'email': self.user.email,
                    'username': self.user.username,
                    'activated': 1,
                },
                {
                    "MailChimp": {
                        "listId": settings.MAILCHIMP_NEW_USER_LIST_ID
                    }
                }
            ]
            analytics.identify(*identity_args)

556

557
class PendingNameChange(models.Model):
Piotr Mitros committed
558 559 560
    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)
561

562

563
class PendingEmailChange(models.Model):
Piotr Mitros committed
564
    user = models.OneToOneField(User, unique=True, db_index=True)
565
    new_email = models.CharField(blank=True, max_length=255, db_index=True)
566
    activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
567

568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584
    def request_change(self, email):
        """Request a change to a user's email.

        Implicitly saves the pending email change record.

        Arguments:
            email (unicode): The proposed new email for the user.

        Returns:
            unicode: The activation code to confirm the change.

        """
        self.new_email = email
        self.activation_key = uuid.uuid4().hex
        self.save()
        return self.activation_key

ichuang committed
585

Gabe Mulley committed
586 587
EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated'
EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
588
EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed'
Gabe Mulley committed
589 590


591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621
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
        """
622 623
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return False
David Baumgold committed
624 625 626
        min_diff_pw = settings.ADVANCED_SECURITY_CONFIG.get(
            'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE', 0
        )
627
        return min_diff_pw > 0
628 629 630 631 632 633

    @classmethod
    def is_staff_password_reuse_restricted(cls):
        """
        Returns whether the configuration which limits password reuse has been turned on
        """
634 635
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return False
David Baumgold committed
636 637 638
        min_diff_pw = settings.ADVANCED_SECURITY_CONFIG.get(
            'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE', 0
        )
639
        return min_diff_pw > 0
640 641 642 643 644 645

    @classmethod
    def is_password_reset_frequency_restricted(cls):
        """
        Returns whether the configuration which limits the password reset frequency has been turned on
        """
646 647
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return False
David Baumgold committed
648 649 650
        min_days_between_reset = settings.ADVANCED_SECURITY_CONFIG.get(
            'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'
        )
651
        return min_days_between_reset
652 653 654 655 656 657

    @classmethod
    def is_staff_forced_password_reset_enabled(cls):
        """
        Returns whether the configuration which forces password resets to occur has been turned on
        """
658 659
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return False
David Baumgold committed
660 661 662
        min_days_between_reset = settings.ADVANCED_SECURITY_CONFIG.get(
            'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS'
        )
663
        return min_days_between_reset
664 665 666 667 668 669

    @classmethod
    def is_student_forced_password_reset_enabled(cls):
        """
        Returns whether the configuration which forces password resets to occur has been turned on
        """
670 671
        if not settings.FEATURES['ADVANCED_SECURITY']:
            return False
David Baumgold committed
672 673 674
        min_days_pw_reset = settings.ADVANCED_SECURITY_CONFIG.get(
            'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS'
        )
675
        return min_days_pw_reset
676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740

    @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

741
        if user.is_staff and cls.is_staff_password_reuse_restricted():
Sarina Canelake committed
742 743
            min_diff_passwords_required = \
                settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE']
744 745 746
        elif cls.is_student_password_reuse_restricted():
            min_diff_passwords_required = \
                settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE']
747 748
        else:
            min_diff_passwords_required = 0
749

750 751 752
        # 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]
753 754 755 756 757 758 759 760 761 762 763 764 765 766 767

        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
768 769 770 771
                AUDIT_LOG.error('''
                                Unknown password hashing algorithm "{0}" found in existing password
                                hash, password reuse policy will not be enforced!!!
                                '''.format(algorithm))
772 773
                return True

774
            if entry.password == hashed_password:
775 776 777 778 779
                return False

        return True


780 781 782 783 784 785 786 787 788
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
789 790 791 792 793 794 795 796 797 798 799 800 801
    def _get_record_for_user(cls, user):
        """
        Gets a user's record, and fixes any duplicates that may have arisen due to get_or_create
        race conditions. See https://code.djangoproject.com/ticket/13906 for details.

        Use this method in place of `LoginFailures.objects.get(user=user)`
        """
        records = LoginFailures.objects.filter(user=user).order_by('-lockout_until')
        for extra_record in records[1:]:
            extra_record.delete()
        return records.get()

    @classmethod
802 803 804 805 806 807 808 809 810 811 812 813
    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:
814
            record = cls._get_record_for_user(user)
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
            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:
849
            entry = cls._get_record_for_user(user)
850 851 852 853 854
            entry.delete()
        except ObjectDoesNotExist:
            return


855 856 857
class CourseEnrollmentException(Exception):
    pass

858

859 860 861
class NonExistentCourseError(CourseEnrollmentException):
    pass

862

863 864 865
class EnrollmentClosedError(CourseEnrollmentException):
    pass

866

867 868 869
class CourseFullError(CourseEnrollmentException):
    pass

870

871 872 873 874
class AlreadyEnrolledError(CourseEnrollmentException):
    pass


875 876 877 878 879 880 881 882 883 884 885 886
class CourseEnrollmentManager(models.Manager):
    """
    Custom manager for CourseEnrollment with Table-level filter methods.
    """

    def num_enrolled_in(self, course_id):
        """
        Returns the count of active enrollments in a course.

        'course_id' is the course_id to return enrollments
        """

887
        enrollment_number = super(CourseEnrollmentManager, self).get_queryset().filter(
888 889 890 891 892 893
            course_id=course_id,
            is_active=1
        ).count()

        return enrollment_number

894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920
    def num_enrolled_in_exclude_admins(self, course_id):
        """
        Returns the count of active enrollments in a course excluding instructors, staff and CCX coaches.

        Arguments:
            course_id (CourseLocator): course_id to return enrollments (count).

        Returns:
            int: Count of enrollments excluding staff, instructors and CCX coaches.

        """
        # To avoid circular imports.
        from student.roles import CourseCcxCoachRole, CourseInstructorRole, CourseStaffRole
        course_locator = course_id

        if getattr(course_id, 'ccx', None):
            course_locator = course_id.to_course_locator()

        staff = CourseStaffRole(course_locator).users_with_role()
        admins = CourseInstructorRole(course_locator).users_with_role()
        coaches = CourseCcxCoachRole(course_locator).users_with_role()

        return super(CourseEnrollmentManager, self).get_queryset().filter(
            course_id=course_id,
            is_active=1,
        ).exclude(user__in=staff).exclude(user__in=admins).exclude(user__in=coaches).count()

921 922 923 924 925 926 927
    def is_course_full(self, 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:
928 929
            is_course_full = self.num_enrolled_in_exclude_admins(course.id) >= course.max_student_enrollments_allowed

930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945
        return is_course_full

    def users_enrolled_in(self, 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
        )

    def enrollment_counts(self, course_id):
        """
        Returns a dictionary that stores the total enrollment count for a course, as well as the
        enrollment count for each individual mode.
        """
        # Unfortunately, Django's "group by"-style queries look super-awkward
        query = use_read_replica_if_available(
946
            super(CourseEnrollmentManager, self).get_queryset().filter(course_id=course_id, is_active=True).values(
947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962
                'mode').order_by().annotate(Count('mode')))
        total = 0
        enroll_dict = defaultdict(int)
        for item in query:
            enroll_dict[item['mode']] = item['mode__count']
            total += item['mode__count']
        enroll_dict['total'] = total
        return enroll_dict

    def enrolled_and_dropped_out_users(self, course_id):
        """Return a queryset of Users in the course."""
        return User.objects.filter(
            courseenrollment__course_id=course_id
        )


963 964 965 966 967 968
# Named tuple for fields pertaining to the state of
# CourseEnrollment for a user in a course.  This type
# is used to cache the state in the request cache.
CourseEnrollmentState = namedtuple('CourseEnrollmentState', 'mode, is_active')


969
class CourseEnrollment(models.Model):
970 971 972 973 974 975 976 977 978 979 980
    """
    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.
    """
981 982
    MODEL_TAGS = ['course_id', 'is_active', 'mode']

983
    user = models.ForeignKey(User)
984
    course_id = CourseKeyField(max_length=255, db_index=True)
985
    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
986

987 988 989 990 991 992
    # 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.
993
    mode = models.CharField(default=CourseMode.DEFAULT_MODE_SLUG, max_length=100)
994

995 996
    objects = CourseEnrollmentManager()

997 998 999
    # Maintain a history of requirement status updates for auditing purposes
    history = HistoricalRecords()

1000 1001 1002
    # cache key format e.g enrollment.<username>.<course_key>.mode = 'honor'
    COURSE_ENROLLMENT_CACHE_KEY = u"enrollment.{}.{}.mode"

1003
    class Meta(object):
1004
        unique_together = (('user', 'course_id'),)
1005
        ordering = ('user', 'course_id')
1006

1007 1008 1009 1010 1011 1012 1013
    def __init__(self, *args, **kwargs):
        super(CourseEnrollment, self).__init__(*args, **kwargs)

        # Private variable for storing course_overview to minimize calls to the database.
        # When the property .course_overview is accessed for the first time, this variable will be set.
        self._course_overview = None

1014
    def __unicode__(self):
1015 1016 1017 1018
        return (
            "[CourseEnrollment] {}: {} ({}); active: ({})"
        ).format(self.user, self.course_id, self.created, self.is_active)

1019 1020 1021 1022 1023 1024 1025
    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
        super(CourseEnrollment, self).save(force_insert=force_insert, force_update=force_update, using=using,
                                           update_fields=update_fields)

        # Delete the cached status hash, forcing the value to be recalculated the next time it is needed.
        cache.delete(self.enrollment_status_hash_cache_key(self.user))

1026
    @classmethod
1027
    def get_or_create_enrollment(cls, user, course_key):
1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048
        """
        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.
1049
        assert isinstance(course_key, CourseKey)
1050

1051 1052 1053
        if user.id is None:
            user.save()

1054 1055 1056 1057 1058 1059 1060 1061
        enrollment, __ = cls.objects.get_or_create(
            user=user,
            course_id=course_key,
            defaults={
                'mode': CourseMode.DEFAULT_MODE_SLUG,
                'is_active': False
            }
        )
Julia Hansbrough committed
1062 1063 1064

        return enrollment

1065
    @classmethod
Awais committed
1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076
    def get_enrollment(cls, user, course_key):
        """Returns a CoursewareEnrollment object.

        Args:
            user (User): The user associated with the enrollment.
            course_id (CourseKey): The key of the course associated with the enrollment.

        Returns:
            Course enrollment object or None
        """
        try:
1077
            return cls.objects.get(
Awais committed
1078 1079 1080 1081 1082 1083 1084
                user=user,
                course_id=course_key
            )
        except cls.DoesNotExist:
            return None

    @classmethod
1085 1086 1087 1088 1089 1090 1091 1092 1093 1094
    def is_enrollment_closed(cls, user, course):
        """
        Returns a boolean value regarding whether the user has access to enroll in the course. Returns False if the
        enrollment has been closed.
        """
        # Disable the pylint error here, as per ormsbee. This local import was previously
        # in CourseEnrollment.enroll
        from courseware.access import has_access  # pylint: disable=import-error
        return not has_access(user, 'enroll', course)

1095
    def update_enrollment(self, mode=None, is_active=None, skip_refund=False):
Julia Hansbrough committed
1096 1097 1098 1099 1100 1101 1102
        """
        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.
1103

Julia Hansbrough committed
1104
        """
Gabe Mulley committed
1105
        activation_changed = False
Julia Hansbrough committed
1106 1107
        # 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
1108 1109
        if self.is_active != is_active and is_active is not None:
            self.is_active = is_active
Gabe Mulley committed
1110 1111 1112
            activation_changed = True

        mode_changed = False
1113
        # if mode is None, the call to update_enrollment didn't specify a new
Julia Hansbrough committed
1114
        # mode, so leave as-is
Julia Hansbrough committed
1115 1116
        if self.mode != mode and mode is not None:
            self.mode = mode
Gabe Mulley committed
1117 1118 1119
            mode_changed = True

        if activation_changed or mode_changed:
Julia Hansbrough committed
1120
            self.save()
1121 1122 1123 1124 1125
            self._update_enrollment_in_request_cache(
                self.user,
                self.course_id,
                CourseEnrollmentState(self.mode, self.is_active),
            )
1126

Julia Hansbrough committed
1127 1128 1129
        if activation_changed:
            if self.is_active:
                self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED)
1130 1131 1132

                dog_stats_api.increment(
                    "common.student.enrollment",
1133 1134
                    tags=[u"org:{}".format(self.course_id.org),
                          u"offering:{}".format(self.course_id.offering),
1135 1136 1137
                          u"mode:{}".format(self.mode)]
                )

1138 1139
            else:
                UNENROLL_DONE.send(sender=None, course_enrollment=self, skip_refund=skip_refund)
1140

Julia Hansbrough committed
1141
                self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
1142
                self.send_signal(EnrollStatusChange.unenroll)
1143

1144 1145
                dog_stats_api.increment(
                    "common.student.unenrollment",
1146 1147
                    tags=[u"org:{}".format(self.course_id.org),
                          u"offering:{}".format(self.course_id.offering),
1148 1149
                          u"mode:{}".format(self.mode)]
                )
1150
        if mode_changed:
1151 1152
            # Only emit mode change events when the user's enrollment
            # mode has changed from its previous setting
1153
            self.emit_event(EVENT_NAME_ENROLLMENT_MODE_CHANGED)
1154

1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172
    def send_signal(self, event, cost=None, currency=None):
        """
        Sends a signal announcing changes in course enrollment status.
        """
        ENROLL_STATUS_CHANGE.send(sender=None, event=event, user=self.user,
                                  mode=self.mode, course_id=self.course_id,
                                  cost=cost, currency=currency)

    @classmethod
    def send_signal_full(cls, event, user=user, mode=mode, course_id=course_id, cost=None, currency=None):
        """
        Sends a signal announcing changes in course enrollment status.
        This version should be used if you don't already have a CourseEnrollment object
        """
        ENROLL_STATUS_CHANGE.send(sender=None, event=event, user=user,
                                  mode=mode, course_id=course_id,
                                  cost=cost, currency=currency)

Gabe Mulley committed
1173 1174 1175 1176 1177 1178 1179
    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)
1180
            assert isinstance(self.course_id, CourseKey)
Gabe Mulley committed
1181 1182
            data = {
                'user_id': self.user.id,
1183
                'course_id': self.course_id.to_deprecated_string(),
Gabe Mulley committed
1184 1185 1186 1187
                'mode': self.mode,
            }

            with tracker.get_tracker().context(event_name, context):
1188
                tracker.emit(event_name, data)
1189

1190
                if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
1191
                    tracking_context = tracker.get_tracker().resolve_context()
1192 1193 1194 1195 1196 1197 1198
                    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,
1199
                    }, context={
1200
                        'ip': tracking_context.get('ip'),
1201 1202 1203
                        'Google Analytics': {
                            'clientId': tracking_context.get('client_id')
                        }
1204
                    })
1205

Gabe Mulley committed
1206 1207
        except:  # pylint: disable=bare-except
            if event_name and self.course_id:
1208 1209 1210
                log.exception(
                    u'Unable to emit event %s for user %s and course %s',
                    event_name,
1211
                    self.user.username,
1212 1213
                    self.course_id,
                )
Gabe Mulley committed
1214

1215
    @classmethod
1216
    def enroll(cls, user, course_key, mode=None, check_access=False):
1217 1218 1219 1220 1221 1222 1223 1224 1225
        """
        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.

Julia Hansbrough committed
1226
        `course_key` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
1227 1228

        `mode` is a string specifying what kind of enrollment this is. The
1229 1230
               default is the default course mode, 'audit'. Other options
               include 'professional', 'verified', 'honor',
1231 1232
               'no-id-professional' and 'credit'.
               See CourseMode in common/djangoapps/course_modes/models.py.
1233

Julia Hansbrough committed
1234 1235 1236 1237 1238 1239
        `check_access`: if True, we check that an accessible course actually
                exists for the given course_key before we enroll the student.
                The default is set to False to avoid breaking legacy code or
                code with non-standard flows (ex. beta tester invitations), but
                for any standard enrollment flow you probably want this to be True.

Julia Hansbrough committed
1240 1241 1242 1243 1244
        Exceptions that can be raised: NonExistentCourseError,
        EnrollmentClosedError, CourseFullError, AlreadyEnrolledError.  All these
        are subclasses of CourseEnrollmentException if you want to catch all of
        them in the same way.

1245
        It is expected that this method is called from a method which has already
1246
        verified the user authentication.
1247 1248

        Also emits relevant events for analytics purposes.
1249
        """
1250 1251
        if mode is None:
            mode = _default_course_mode(unicode(course_key))
1252 1253
        # All the server-side checks for whether a user is allowed to enroll.
        try:
1254 1255 1256 1257 1258 1259 1260
            course = CourseOverview.get_from_id(course_key)
        except CourseOverview.DoesNotExist:
            # This is here to preserve legacy behavior which allowed enrollment in courses
            # announced before the start of content creation.
            if check_access:
                log.warning(u"User %s failed to enroll in non-existent course %s", user.username, unicode(course_key))
                raise NonExistentCourseError
Julia Hansbrough committed
1261

Julia Hansbrough committed
1262
        if check_access:
1263
            if cls.is_enrollment_closed(user, course):
Julia Hansbrough committed
1264
                log.warning(
1265 1266 1267
                    u"User %s failed to enroll in course %s because enrollment is closed",
                    user.username,
                    course_key.to_deprecated_string()
1268
                )
Julia Hansbrough committed
1269 1270
                raise EnrollmentClosedError

1271
            if cls.objects.is_course_full(course):
Julia Hansbrough committed
1272
                log.warning(
1273
                    u"Course %s has reached its maximum enrollment of %d learners. User %s failed to enroll.",
1274
                    course_key.to_deprecated_string(),
1275 1276
                    course.max_student_enrollments_allowed,
                    user.username,
1277
                )
Julia Hansbrough committed
1278
                raise CourseFullError
1279
        if cls.is_enrolled(user, course_key):
1280
            log.warning(
1281 1282 1283
                u"User %s attempted to enroll in %s, but they were already enrolled",
                user.username,
                course_key.to_deprecated_string()
1284
            )
Julia Hansbrough committed
1285 1286
            if check_access:
                raise AlreadyEnrolledError
1287 1288

        # User is allowed to enroll if they've reached this point.
1289
        enrollment = cls.get_or_create_enrollment(user, course_key)
1290
        enrollment.update_enrollment(is_active=True, mode=mode)
1291
        enrollment.send_signal(EnrollStatusChange.enroll)
1292

Julia Hansbrough committed
1293
        return enrollment
1294 1295

    @classmethod
1296
    def enroll_by_email(cls, email, course_id, mode=None, ignore_errors=True):
1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311
        """
        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
1312 1313 1314 1315
               default is the default course mode, 'audit'. Other options
               include 'professional', 'verified', 'honor',
               'no-id-professional' and 'credit'.
               See CourseMode in common/djangoapps/course_modes/models.py.
1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334

        `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
1335
    def unenroll(cls, user, course_id, skip_refund=False):
1336 1337 1338 1339 1340 1341 1342 1343 1344
        """
        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)
1345

1346
        `skip_refund` can be set to True to avoid the refund process.
1347 1348
        """
        try:
1349
            record = cls.objects.get(user=user, course_id=course_id)
1350
            record.update_enrollment(is_active=False, skip_refund=skip_refund)
Gabe Mulley committed
1351

1352
        except cls.DoesNotExist:
1353 1354 1355 1356 1357
            log.error(
                u"Tried to unenroll student %s from %s but they were not enrolled",
                user,
                course_id
            )
1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372

    @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:
1373 1374 1375 1376 1377
            log.error(
                u"Tried to unenroll email %s from course %s, but user not found",
                email,
                course_id
            )
1378 1379

    @classmethod
1380
    def is_enrolled(cls, user, course_key):
1381 1382 1383 1384 1385 1386 1387 1388 1389 1390
        """
        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)
        """
1391 1392
        if not user.is_authenticated():
            return False
1393 1394 1395
        else:
            enrollment_state = cls._get_enrollment_state(user, course_key)
            return enrollment_state.is_active or False
1396 1397

    @classmethod
1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409
    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.

1410
        `course_id_partial` (CourseKey) is missing the run component
1411
        """
1412
        assert isinstance(course_id_partial, CourseKey)
1413 1414 1415
        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())
1416
        try:
1417
            return cls.objects.filter(
Julian Arni committed
1418
                user=user,
1419
                course_id__startswith=querystring,
Julian Arni committed
1420 1421
                is_active=1
            ).exists()
1422 1423 1424 1425
        except cls.DoesNotExist:
            return False

    @classmethod
1426 1427 1428 1429 1430 1431
    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)
1432

1433 1434 1435
        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.
1436
        """
1437 1438
        enrollment_state = cls._get_enrollment_state(user, course_id)
        return enrollment_state.mode, enrollment_state.is_active
1439 1440

    @classmethod
1441
    def enrollments_for_user(cls, user):
1442
        return cls.objects.filter(user=user, is_active=1).select_related('user')
1443

1444
    @classmethod
1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469
    def enrollments_for_user_with_overviews_preload(cls, user):  # pylint: disable=invalid-name
        """
        List of user's CourseEnrollments, CourseOverviews preloaded if possible.

        We try to preload all CourseOverviews, which are usually lazily loaded
        as the .course_overview property. This is to avoid making an extra
        query for every enrollment when displaying something like the student
        dashboard. If some of the CourseOverviews are not found, we make no
        attempt to initialize them -- we just fall back to existing lazy-load
        behavior. The goal is to optimize the most common case as simply as
        possible, without changing any of the existing contracts.

        The name of this method is long, but was the end result of hashing out a
        number of alternatives, so pylint can stuff it (disable=invalid-name)
        """
        enrollments = list(cls.enrollments_for_user(user))
        overviews = CourseOverview.get_from_ids_if_exists(
            enrollment.course_id for enrollment in enrollments
        )
        for enrollment in enrollments:
            enrollment._course_overview = overviews.get(enrollment.course_id)  # pylint: disable=protected-access

        return enrollments

    @classmethod
1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509
    def enrollment_status_hash_cache_key(cls, user):
        """ Returns the cache key for the cached enrollment status hash.

        Args:
            user (User): User whose cache key should be returned.

        Returns:
            str: Cache key.
        """
        return 'enrollment_status_hash_' + user.username

    @classmethod
    def generate_enrollment_status_hash(cls, user):
        """ Generates a hash encoding the given user's *active* enrollments.

         Args:
             user (User): User whose enrollments should be hashed.

        Returns:
            str: Hash of the user's active enrollments. If the user is anonymous, `None` is returned.
        """
        if user.is_anonymous():
            return None

        cache_key = cls.enrollment_status_hash_cache_key(user)
        status_hash = cache.get(cache_key)

        if not status_hash:
            enrollments = cls.enrollments_for_user(user).values_list('course_id', 'mode')
            enrollments = [(e[0].lower(), e[1].lower()) for e in enrollments]
            enrollments = sorted(enrollments, key=lambda e: e[0])
            hash_elements = [user.username]
            hash_elements += ['{course_id}={mode}'.format(course_id=e[0], mode=e[1]) for e in enrollments]
            status_hash = hashlib.md5('&'.join(hash_elements).encode('utf-8')).hexdigest()

            # The hash is cached indefinitely. It will be invalidated when the user enrolls/unenrolls.
            cache.set(cache_key, status_hash, None)

        return status_hash

1510 1511 1512 1513
    def is_paid_course(self):
        """
        Returns True, if course is paid
        """
1514 1515
        paid_course = CourseMode.is_white_label(self.course_id)
        if paid_course or CourseMode.is_professional_slug(self.mode):
1516 1517 1518 1519
            return True

        return False

1520 1521
    def activate(self):
        """Makes this `CourseEnrollment` record active. Saves immediately."""
Julia Hansbrough committed
1522
        self.update_enrollment(is_active=True)
1523 1524 1525 1526 1527

    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
1528
        self.update_enrollment(is_active=False)
1529 1530

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

1534 1535
    def refundable(self):
        """
1536
        For paid/verified certificates, students may receive a refund if they have
1537 1538
        a verified certificate and the deadline for refunds has not yet passed.
        """
1539
        # In order to support manual refunds past the deadline, set can_refund on this object.
Sarina Canelake committed
1540
        # On unenrolling, the "UNENROLL_DONE" signal calls CertificateItem.refund_cert_callback(),
1541 1542 1543 1544 1545
        # 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
1546 1547 1548 1549 1550

        # 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

1551 1552
        # If it is after the refundable cutoff date they should not be refunded.
        refund_cutoff_date = self.refund_cutoff_date()
1553
        if refund_cutoff_date and datetime.now(UTC) > refund_cutoff_date:
1554
            return False
1555

1556 1557 1558 1559 1560 1561
        course_mode = CourseMode.mode_for_course(self.course_id, 'verified')
        if course_mode is None:
            return False
        else:
            return True

1562 1563
    def refund_cutoff_date(self):
        """ Calculate and return the refund window end date. """
1564 1565 1566
        # NOTE: This is here to avoid circular references
        from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT

1567
        try:
1568
            attribute = self.attributes.get(namespace='order', name='order_number')
1569 1570
        except ObjectDoesNotExist:
            return None
1571 1572 1573 1574 1575 1576 1577 1578 1579
        except MultipleObjectsReturned:
            # If there are multiple attributes then return the last one.
            enrollment_id = self.get_enrollment(self.user, self.course_id).id
            log.warning(
                u"Multiple CourseEnrollmentAttributes found for user %s with enrollment-ID %s",
                self.user.id,
                enrollment_id
            )
            attribute = self.attributes.filter(namespace='order', name='order_number').last()
1580 1581 1582 1583 1584 1585 1586 1587

        order_number = attribute.value
        order = ecommerce_api_client(self.user).orders(order_number).get()
        refund_window_start_date = max(
            datetime.strptime(order['date_placed'], ECOMMERCE_DATE_FORMAT),
            self.course_overview.start.replace(tzinfo=None)
        )

1588
        return refund_window_start_date.replace(tzinfo=UTC) + EnrollmentRefundConfiguration.current().refund_window
1589

1590 1591 1592 1593 1594 1595
    @property
    def username(self):
        return self.user.username

    @property
    def course(self):
1596 1597
        # Deprecated. Please use the `course_overview` property instead.
        return self.course_overview
1598

1599 1600 1601
    @property
    def course_overview(self):
        """
1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615
        Returns a CourseOverview of the course to which this enrollment refers.
        Returns None if an error occurred while trying to load the course.

        Note:
            If the course is re-published within the lifetime of this
            CourseEnrollment object, then the value of this property will
            become stale.
       """
        if not self._course_overview:
            try:
                self._course_overview = CourseOverview.get_from_id(self.course_id)
            except (CourseOverview.DoesNotExist, IOError):
                self._course_overview = None
        return self._course_overview
1616

1617 1618 1619 1620 1621 1622
    def is_verified_enrollment(self):
        """
        Check the course enrollment mode is verified or not
        """
        return CourseMode.is_verified_slug(self.mode)

1623 1624 1625 1626 1627 1628
    def is_professional_enrollment(self):
        """
        Check the course enrollment mode is professional or not
        """
        return CourseMode.is_professional_slug(self.mode)

1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659
    @classmethod
    def is_enrolled_as_verified(cls, user, course_key):
        """
        Check whether the course enrollment is for a verified mode.

        Arguments:
            user (User): The user object.
            course_key (CourseKey): The identifier for the course.

        Returns: bool

        """
        enrollment = cls.get_enrollment(user, course_key)
        return (
            enrollment is not None and
            enrollment.is_active and
            enrollment.is_verified_enrollment()
        )

    @classmethod
    def cache_key_name(cls, user_id, course_key):
        """Return cache key name to be used to cache current configuration.
        Args:
            user_id(int): Id of user.
            course_key(unicode): Unicode of course key

        Returns:
            Unicode cache key
        """
        return cls.COURSE_ENROLLMENT_CACHE_KEY.format(user_id, unicode(course_key))

1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698
    @classmethod
    def _get_enrollment_state(cls, user, course_key):
        """
        Returns the CourseEnrollmentState for the given user
        and course_key, caching the result for later retrieval.
        """
        enrollment_state = cls._get_enrollment_in_request_cache(user, course_key)
        if not enrollment_state:
            try:
                record = cls.objects.get(user=user, course_id=course_key)
                enrollment_state = CourseEnrollmentState(record.mode, record.is_active)
            except cls.DoesNotExist:
                enrollment_state = CourseEnrollmentState(None, None)
            cls._update_enrollment_in_request_cache(user, course_key, enrollment_state)
        return enrollment_state

    @classmethod
    def _get_mode_active_request_cache(cls):
        """
        Returns the request-specific cache for CourseEnrollment
        """
        return request_cache.get_cache('CourseEnrollment.mode_and_active')

    @classmethod
    def _get_enrollment_in_request_cache(cls, user, course_key):
        """
        Returns the cached value (CourseEnrollmentState) for the user's
        enrollment in the request cache.  If not cached, returns None.
        """
        return cls._get_mode_active_request_cache().get((user.id, course_key))

    @classmethod
    def _update_enrollment_in_request_cache(cls, user, course_key, enrollment_state):
        """
        Updates the cached value for the user's enrollment in the
        request cache.
        """
        cls._get_mode_active_request_cache()[(user.id, course_key)] = enrollment_state

1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710

@receiver(models.signals.post_save, sender=CourseEnrollment)
@receiver(models.signals.post_delete, sender=CourseEnrollment)
def invalidate_enrollment_mode_cache(sender, instance, **kwargs):  # pylint: disable=unused-argument, invalid-name
    """Invalidate the cache of CourseEnrollment model. """

    cache_key = CourseEnrollment.cache_key_name(
        instance.user.id,
        unicode(instance.course_id)
    )
    cache.delete(cache_key)

1711

1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727
class ManualEnrollmentAudit(models.Model):
    """
    Table for tracking which enrollments were performed through manual enrollment.
    """
    enrollment = models.ForeignKey(CourseEnrollment, null=True)
    enrolled_by = models.ForeignKey(User, null=True)
    enrolled_email = models.CharField(max_length=255, db_index=True)
    time_stamp = models.DateTimeField(auto_now_add=True, null=True)
    state_transition = models.CharField(max_length=255, choices=TRANSITION_STATES)
    reason = models.TextField(null=True)

    @classmethod
    def create_manual_enrollment_audit(cls, user, email, state_transition, reason, enrollment=None):
        """
        saves the student manual enrollment information
        """
1728
        return cls.objects.create(
1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758
            enrolled_by=user,
            enrolled_email=email,
            state_transition=state_transition,
            reason=reason,
            enrollment=enrollment
        )

    @classmethod
    def get_manual_enrollment_by_email(cls, email):
        """
        if matches returns the most recent entry in the table filtered by email else returns None.
        """
        try:
            manual_enrollment = cls.objects.filter(enrolled_email=email).latest('time_stamp')
        except cls.DoesNotExist:
            manual_enrollment = None
        return manual_enrollment

    @classmethod
    def get_manual_enrollment(cls, enrollment):
        """
        if matches returns the most recent entry in the table filtered by enrollment else returns None,
        """
        try:
            manual_enrollment = cls.objects.filter(enrollment=enrollment).latest('time_stamp')
        except cls.DoesNotExist:
            manual_enrollment = None
        return manual_enrollment


1759 1760 1761 1762 1763 1764 1765
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)
1766
    course_id = CourseKeyField(max_length=255, db_index=True)
1767
    auto_enroll = models.BooleanField(default=0)
1768 1769 1770

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

1771
    class Meta(object):
1772
        unique_together = (('email', 'course_id'),)
1773

1774 1775
    def __unicode__(self):
        return "[CourseEnrollmentAllowed] %s: %s (%s)" % (self.email, self.course_id, self.created)
1776

1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789
    @classmethod
    def may_enroll_and_unenrolled(cls, course_id):
        """
        Return QuerySet of students who are allowed to enroll in a course.

        Result excludes students who have already enrolled in the
        course.

        `course_id` identifies the course for which to compute the QuerySet.
        """
        enrolled = CourseEnrollment.objects.users_enrolled_in(course_id=course_id).values_list('email', flat=True)
        return CourseEnrollmentAllowed.objects.filter(course_id=course_id).exclude(email__in=enrolled)

1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807

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

1808
    class Meta(object):
1809 1810 1811 1812 1813 1814 1815 1816
        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
        """
1817
        return (self.role, self.org, self.course_id, self.user_id)
1818 1819 1820 1821 1822 1823

    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.
        """
Sarina Canelake committed
1824
        return type(self) == type(other) and self._key == other._key  # pylint: disable=protected-access
1825 1826 1827 1828 1829 1830 1831 1832

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

    def __lt__(self, other):
        """
        Lexigraphic sort
        """
Sarina Canelake committed
1833
        return self._key < other._key  # pylint: disable=protected-access
1834

1835 1836 1837
    def __unicode__(self):
        return "[CourseAccessRole] user: {}   role: {}   org: {}   course: {}".format(self.user.username, self.role, self.org, self.course_id)

1838

1839 1840
#### Helper methods for use from python manage.py shell and other classes.

Calen Pennington committed
1841

1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853
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)
1854

1855

1856
def get_user(email):
Sarina Canelake committed
1857 1858 1859
    user = User.objects.get(email=email)
    u_prof = UserProfile.objects.get(user=user)
    return user, u_prof
1860

1861 1862

def user_info(email):
Sarina Canelake committed
1863 1864 1865 1866 1867 1868 1869 1870
    user, u_prof = get_user(email)
    print "User id", user.id
    print "Username", user.username
    print "E-mail", user.email
    print "Name", u_prof.name
    print "Location", u_prof.location
    print "Language", u_prof.language
    return user, u_prof
1871

1872 1873

def change_email(old_email, new_email):
Sarina Canelake committed
1874 1875 1876
    user = User.objects.get(email=old_email)
    user.email = new_email
    user.save()
1877

1878

1879
def change_name(email, new_name):
Sarina Canelake committed
1880 1881 1882
    _user, u_prof = get_user(email)
    u_prof.name = new_name
    u_prof.save()
1883

1884

Piotr Mitros committed
1885
def user_count():
Piotr Mitros committed
1886
    print "All users", User.objects.all().count()
1887
    print "Active users", User.objects.filter(is_active=True).count()
Piotr Mitros committed
1888 1889
    return User.objects.all().count()

1890

Piotr Mitros committed
1891
def active_user_count():
1892 1893
    return User.objects.filter(is_active=True).count()

Piotr Mitros committed
1894

Piotr Mitros committed
1895 1896 1897 1898 1899 1900
def create_group(name, description):
    utg = UserTestGroup()
    utg.name = name
    utg.description = description
    utg.save()

1901

1902
def add_user_to_group(user, group):
1903 1904
    utg = UserTestGroup.objects.get(name=group)
    utg.users.add(User.objects.get(username=user))
Piotr Mitros committed
1905
    utg.save()
1906

1907

1908
def remove_user_from_group(user, group):
1909 1910
    utg = UserTestGroup.objects.get(name=group)
    utg.users.remove(User.objects.get(username=user))
1911
    utg.save()
Piotr Mitros committed
1912

Sarina Canelake committed
1913 1914 1915 1916 1917 1918
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'
}
1919

1920 1921 1922

def add_user_to_default_group(user, group):
    try:
1923
        utg = UserTestGroup.objects.get(name=group)
1924
    except UserTestGroup.DoesNotExist:
1925 1926
        utg = UserTestGroup()
        utg.name = group
Sarina Canelake committed
1927
        utg.description = DEFAULT_GROUPS[group]
1928
        utg.save()
1929
    utg.users.add(User.objects.get(username=user))
Piotr Mitros committed
1930
    utg.save()
Rocky Duan committed
1931

ichuang committed
1932

1933
def create_comments_service_user(user):
1934
    if not settings.FEATURES['ENABLE_DISCUSSION_SERVICE']:
1935 1936
        # Don't try--it won't work, and it will fill the logs with lots of errors
        return
Rocky Duan committed
1937
    try:
1938
        cc_user = cc.User.from_django_user(user)
1939
        cc_user.save()
Sarina Canelake committed
1940 1941
    except Exception:  # pylint: disable=broad-except
        log = logging.getLogger("edx.discussion")  # pylint: disable=redefined-outer-name
1942 1943
        log.error(
            "Could not create comments service user with id {}".format(user.id),
Sarina Canelake committed
1944 1945
            exc_info=True
        )
1946 1947 1948 1949 1950 1951 1952 1953

# 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)
Sarina Canelake committed
1954
def log_successful_login(sender, request, user, **kwargs):  # pylint: disable=unused-argument
1955
    """Handler to log when logins have occurred successfully."""
1956 1957 1958 1959
    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))
1960 1961 1962


@receiver(user_logged_out)
Sarina Canelake committed
1963
def log_successful_logout(sender, request, user, **kwargs):  # pylint: disable=unused-argument
1964
    """Handler to log when logouts have occurred successfully."""
1965 1966 1967 1968 1969
    if hasattr(request, 'user'):
        if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
            AUDIT_LOG.info(u"Logout - user.id: {0}".format(request.user.id))  # pylint: disable=logging-format-interpolation
        else:
            AUDIT_LOG.info(u"Logout - {0}".format(request.user))  # pylint: disable=logging-format-interpolation
1970 1971 1972 1973


@receiver(user_logged_in)
@receiver(user_logged_out)
Sarina Canelake committed
1974
def enforce_single_login(sender, request, user, signal, **kwargs):    # pylint: disable=unused-argument
1975 1976 1977 1978 1979 1980 1981 1982 1983
    """
    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
1984
        if user:
1985 1986 1987 1988 1989 1990
            user_profile, __ = UserProfile.objects.get_or_create(
                user=user,
                defaults={'name': user.username}
            )
            if user_profile:
                user.profile.set_login_session(key)
1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007


class DashboardConfiguration(ConfigurationModel):
    """Dashboard Configuration settings.

    Includes configuration options for the dashboard, which impact behavior and rendering for the application.

    """
    recent_enrollment_time_delta = models.PositiveIntegerField(
        default=0,
        help_text="The number of seconds in which a new enrollment is considered 'recent'. "
                  "Used to display notifications."
    )

    @property
    def recent_enrollment_seconds(self):
        return self.recent_enrollment_time_delta
2008 2009 2010 2011 2012


class LinkedInAddToProfileConfiguration(ConfigurationModel):
    """
    LinkedIn Add to Profile Configuration
2013 2014 2015 2016 2017 2018 2019

    This configuration enables the "Add to Profile" LinkedIn
    button on the student dashboard.  The button appears when
    users have a certificate available; when clicked,
    users are sent to the LinkedIn site with a pre-filled
    form allowing them to add the certificate to their
    LinkedIn profile.
2020
    """
2021 2022

    MODE_TO_CERT_NAME = {
2023 2024 2025 2026
        "honor": _(u"{platform_name} Honor Code Certificate for {course_name}"),
        "verified": _(u"{platform_name} Verified Certificate for {course_name}"),
        "professional": _(u"{platform_name} Professional Certificate for {course_name}"),
        "no-id-professional": _(
2027 2028
            u"{platform_name} Professional Certificate for {course_name}"
        ),
2029 2030 2031
    }

    company_identifier = models.TextField(
2032
        help_text=_(
2033
            u"The company identifier for the LinkedIn Add-to-Profile button "
2034 2035 2036 2037
            u"e.g 0_0dPSPyS070e0HsE9HNz_13_d11_"
        )
    )

2038 2039 2040
    # Deprecated
    dashboard_tracking_code = models.TextField(default="", blank=True)

2041 2042 2043 2044
    trk_partner_name = models.CharField(
        max_length=10,
        default="",
        blank=True,
2045
        help_text=_(
2046 2047 2048 2049 2050 2051 2052
            u"Short identifier for the LinkedIn partner used in the tracking code.  "
            u"(Example: 'edx')  "
            u"If no value is provided, tracking codes will not be sent to LinkedIn."
        )
    )

    def add_to_profile_url(self, course_key, course_name, cert_mode, cert_url, source="o", target="dashboard"):
2053
        """Construct the URL for the "add to profile" button.
2054

2055
        Arguments:
2056
            course_key (CourseKey): The identifier for the course.
2057
            course_name (unicode): The display name of the course.
2058
            cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional")
2059 2060 2061 2062
            cert_url (str): The download URL for the certificate.

        Keyword Arguments:
            source (str): Either "o" (for onsite/UI), "e" (for emails), or "m" (for mobile)
2063
            target (str): An identifier for the occurrance of the button.
2064 2065

        """
2066
        company_identifier = configuration_helpers.get_value('LINKEDIN_COMPANY_ID', self.company_identifier)
2067
        params = OrderedDict([
2068
            ('_ed', company_identifier),
2069
            ('pfCertificationName', self._cert_name(course_name, cert_mode).encode('utf-8')),
2070 2071 2072
            ('pfCertificationUrl', cert_url),
            ('source', source)
        ])
2073 2074 2075 2076 2077

        tracking_code = self._tracking_code(course_key, cert_mode, target)
        if tracking_code is not None:
            params['trk'] = tracking_code

2078 2079 2080 2081
        return u'http://www.linkedin.com/profile/add?{params}'.format(
            params=urlencode(params)
        )

2082
    def _cert_name(self, course_name, cert_mode):
2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093
        """
        Name of the certification, for display on LinkedIn.

        Arguments:
            course_name (unicode): The display name of the course.
            cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional")

        Returns:
            str: The formatted string to display for the name field on the LinkedIn Add to Profile dialog.
        """
        default_cert_name = self.MODE_TO_CERT_NAME.get(
2094
            cert_mode,
2095
            _(u"{platform_name} Certificate for {course_name}")
2096 2097 2098 2099 2100 2101
        )
        # Look for an override of the certificate name in the SOCIAL_SHARING_SETTINGS setting
        share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS)
        cert_name = share_settings.get('CERTIFICATE_LINKEDIN_MODE_TO_CERT_NAME', {}).get(cert_mode, default_cert_name)

        return cert_name.format(
2102
            platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
2103 2104
            course_name=course_name
        )
2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140

    def _tracking_code(self, course_key, cert_mode, target):
        """Create a tracking code for the button.

        Tracking codes are used by LinkedIn to collect
        analytics about certifications users are adding
        to their profiles.

        The tracking code format is:
            &trk=[partner name]-[certificate type]-[date]-[target field]

        In our case, we're sending:
            &trk=edx-{COURSE ID}_{COURSE MODE}-{TARGET}

        If no partner code is configured, then this will
        return None, indicating that tracking codes are disabled.

        Arguments:

            course_key (CourseKey): The identifier for the course.
            cert_mode (str): The enrollment mode for the course.
            target (str): Identifier for where the button is located.

        Returns:
            unicode or None

        """
        return (
            u"{partner}-{course_key}_{cert_mode}-{target}".format(
                partner=self.trk_partner_name,
                course_key=unicode(course_key),
                cert_mode=cert_mode,
                target=target
            )
            if self.trk_partner_name else None
        )
2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170


class EntranceExamConfiguration(models.Model):
    """
    Represents a Student's entrance exam specific data for a single Course
    """

    user = models.ForeignKey(User, db_index=True)
    course_id = CourseKeyField(max_length=255, db_index=True)
    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
    updated = models.DateTimeField(auto_now=True, db_index=True)

    # if skip_entrance_exam is True, then student can skip entrance exam
    # for the course
    skip_entrance_exam = models.BooleanField(default=True)

    class Meta(object):
        unique_together = (('user', 'course_id'), )

    def __unicode__(self):
        return "[EntranceExamConfiguration] %s: %s (%s) = %s" % (
            self.user, self.course_id, self.created, self.skip_entrance_exam
        )

    @classmethod
    def user_can_skip_entrance_exam(cls, user, course_key):
        """
        Return True if given user can skip entrance exam for given course otherwise False.
        """
        can_skip = False
2171
        if is_entrance_exams_enabled():
2172 2173 2174 2175 2176 2177
            try:
                record = EntranceExamConfiguration.objects.get(user=user, course_id=course_key)
                can_skip = record.skip_entrance_exam
            except EntranceExamConfiguration.DoesNotExist:
                can_skip = False
        return can_skip
2178 2179


2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203
class LanguageField(models.CharField):
    """Represents a language from the ISO 639-1 language set."""

    def __init__(self, *args, **kwargs):
        """Creates a LanguageField.

        Accepts all the same kwargs as a CharField, except for max_length and
        choices. help_text defaults to a description of the ISO 639-1 set.
        """
        kwargs.pop('max_length', None)
        kwargs.pop('choices', None)
        help_text = kwargs.pop(
            'help_text',
            _("The ISO 639-1 language code for this language."),
        )
        super(LanguageField, self).__init__(
            max_length=16,
            choices=settings.ALL_LANGUAGES,
            help_text=help_text,
            *args,
            **kwargs
        )


2204 2205 2206
class LanguageProficiency(models.Model):
    """
    Represents a user's language proficiency.
2207 2208 2209 2210

    Note that we have not found a way to emit analytics change events by using signals directly on this
    model or on UserProfile. Therefore if you are changing LanguageProficiency values, it is important
    to go through the accounts API (AccountsView) defined in
2211 2212
    /edx-platform/openedx/core/djangoapps/user_api/accounts/views.py or its associated api method
    (update_account_settings) so that the events are emitted.
2213
    """
2214
    class Meta(object):
2215 2216 2217 2218 2219 2220 2221
        unique_together = (('code', 'user_profile'),)

    user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='language_proficiencies')
    code = models.CharField(
        max_length=16,
        blank=False,
        choices=settings.ALL_LANGUAGES,
2222
        help_text=_("The ISO 639-1 language code for this language.")
2223
    )
2224 2225 2226 2227


class CourseEnrollmentAttribute(models.Model):
    """
2228 2229 2230
    Provide additional information about the user's enrollment.
    """
    enrollment = models.ForeignKey(CourseEnrollment, related_name="attributes")
2231 2232
    namespace = models.CharField(
        max_length=255,
2233
        help_text=_("Namespace of enrollment attribute")
2234 2235 2236
    )
    name = models.CharField(
        max_length=255,
2237
        help_text=_("Name of the enrollment attribute")
2238 2239 2240
    )
    value = models.CharField(
        max_length=255,
2241
        help_text=_("Value of the enrollment attribute")
2242
    )
2243 2244 2245 2246 2247 2248 2249 2250

    def __unicode__(self):
        """Unicode representation of the attribute. """
        return u"{namespace}:{name}, {value}".format(
            namespace=self.namespace,
            name=self.name,
            value=self.value,
        )
2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294

    @classmethod
    def add_enrollment_attr(cls, enrollment, data_list):
        """Delete all the enrollment attributes for the given enrollment and
        add new attributes.

        Args:
            enrollment(CourseEnrollment): 'CourseEnrollment' for which attribute is to be added
            data(list): list of dictionaries containing data to save
        """
        cls.objects.filter(enrollment=enrollment).delete()
        attributes = [
            cls(enrollment=enrollment, namespace=data['namespace'], name=data['name'], value=data['value'])
            for data in data_list
        ]
        cls.objects.bulk_create(attributes)

    @classmethod
    def get_enrollment_attributes(cls, enrollment):
        """Retrieve list of all enrollment attributes.

        Args:
            enrollment(CourseEnrollment): 'CourseEnrollment' for which list is to retrieve

        Returns: list

        Example:
        >>> CourseEnrollmentAttribute.get_enrollment_attributes(CourseEnrollment)
        [
            {
                "namespace": "credit",
                "name": "provider_id",
                "value": "hogwarts",
            },
        ]
        """
        return [
            {
                "namespace": attribute.namespace,
                "name": attribute.name,
                "value": attribute.value,
            }
            for attribute in cls.objects.filter(enrollment=enrollment)
        ]
2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325


class EnrollmentRefundConfiguration(ConfigurationModel):
    """
    Configuration for course enrollment refunds.
    """

    # TODO: Django 1.8 introduces a DurationField
    # (https://docs.djangoproject.com/en/1.8/ref/models/fields/#durationfield)
    # for storing timedeltas which uses MySQL's bigint for backing
    # storage. After we've completed the Django upgrade we should be
    # able to replace this field with a DurationField named
    # `refund_window` without having to run a migration or change
    # other code.
    refund_window_microseconds = models.BigIntegerField(
        default=1209600000000,
        help_text=_(
            "The window of time after enrolling during which users can be granted"
            " a refund, represented in microseconds. The default is 14 days."
        )
    )

    @property
    def refund_window(self):
        """Return the configured refund window as a `datetime.timedelta`."""
        return timedelta(microseconds=self.refund_window_microseconds)

    @refund_window.setter
    def refund_window(self, refund_window):
        """Set the current refund window to the given timedelta."""
        self.refund_window_microseconds = int(refund_window.total_seconds() * 1000000)
2326 2327


2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349
class RegistrationCookieConfiguration(ConfigurationModel):
    """
    Configuration for registration cookies.
    """
    utm_cookie_name = models.CharField(
        max_length=255,
        help_text=_("Name of the UTM cookie")
    )

    affiliate_cookie_name = models.CharField(
        max_length=255,
        help_text=_("Name of the affiliate cookie")
    )

    def __unicode__(self):
        """Unicode representation of this config. """
        return u"UTM: {utm_name}; AFFILIATE: {affiliate_name}".format(
            utm_name=self.utm_cookie_name,
            affiliate_name=self.affiliate_cookie_name
        )


2350 2351 2352 2353 2354 2355 2356
class UserAttribute(TimeStampedModel):
    """
    Record additional metadata about a user, stored as key/value pairs of text.
    """

    class Meta(object):
        # Ensure that at most one value exists for a given user/name.
2357
        unique_together = (('user', 'name',), )
2358 2359

    user = models.ForeignKey(User, related_name='attributes')
2360
    name = models.CharField(max_length=255, help_text=_("Name of this user attribute."), db_index=True)
2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390
    value = models.CharField(max_length=255, help_text=_("Value of this user attribute."))

    def __unicode__(self):
        """Unicode representation of this attribute. """
        return u"[{username}] {name}: {value}".format(
            name=self.name,
            value=self.value,
            username=self.user.username,
        )

    @classmethod
    def set_user_attribute(cls, user, name, value):
        """
        Add an name/value pair as an attribute for the given
        user. Overwrites any previous value for that name, if it
        exists.
        """
        cls.objects.filter(user=user, name=name).delete()
        cls.objects.create(user=user, name=name, value=value)

    @classmethod
    def get_user_attribute(cls, user, name):
        """
        Return the attribute value for the given user and name. If no such
        value exists, returns None.
        """
        try:
            return cls.objects.get(user=user, name=name).value
        except cls.DoesNotExist:
            return None
2391 2392 2393 2394 2395 2396 2397 2398


class LogoutViewConfiguration(ConfigurationModel):
    """ Configuration for the logout view. """

    def __unicode__(self):
        """Unicode representation of the instance. """
        return u'Logout view configuration: {enabled}'.format(enabled=self.enabled)