diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 3b36efb4a977..12c93da3bd26 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -482,6 +482,66 @@ def test_entrance_exam_store_default_min_score(self): self.assertTrue(course.entrance_exam_enabled) self.assertEqual(course.entrance_exam_minimum_score_pct, .5) + @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) + @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) + def test_entrance_after_changing_other_setting(self): + """ + Test entrance exam is not deactivated when prerequisites removed. + + This test ensures that the entrance milestone is not deactivated after + course details are saves without pre-requisite courses active. + + The test was implemented after a bug fixing, correcting the behaviour + that every time course details were saved, + if there wasn't any pre-requisite course in the POST + the view just deleted all the pre-requisite courses, including entrance exam, + despite the fact that the entrance_exam_enabled was True. + """ + assert not milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \ + 'The initial empty state should be: no entrance exam' + + settings_details_url = get_url(self.course.id) + data = { + 'entrance_exam_enabled': 'true', + 'entrance_exam_minimum_score_pct': '60', + 'syllabus': 'none', + 'short_description': 'empty', + 'overview': '', + 'effort': '', + 'intro_video': '', + 'start_date': '2012-01-01', + 'end_date': '2012-12-31', + } + response = self.client.post( + settings_details_url, + data=json.dumps(data), + content_type='application/json', + HTTP_ACCEPT='application/json' + ) + + assert response.status_code == 200 + course = modulestore().get_course(self.course.id) + assert course.entrance_exam_enabled + assert course.entrance_exam_minimum_score_pct == .60 + + assert milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \ + 'The entrance exam should be required.' + + # Call the settings handler again then ensure it didn't delete the settings of the entrance exam + data.update({ + 'start_date': '2018-01-01', + 'end_date': '{year}-12-31'.format(year=datetime.datetime.now().year + 4), + }) + response = self.client.post( + settings_details_url, + data=json.dumps(data), + content_type='application/json', + HTTP_ACCEPT='application/json' + ) + assert response.status_code == 200 + assert milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \ + 'The entrance exam should be required.' + def test_editable_short_description_fetch(self): settings_details_url = get_url(self.course.id) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 55464820d0d5..92a8e7dbeb0f 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -56,7 +56,9 @@ is_prerequisite_courses_enabled, is_valid_course_key, remove_prerequisite_course, - set_prerequisite_courses + set_prerequisite_courses, + get_namespace_choices, + generate_milestone_namespace ) from common.djangoapps.util.string_utils import _has_non_ascii_characters from common.djangoapps.xblock_django.api import deprecated_xblocks @@ -1233,7 +1235,7 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab ) return render_to_response('settings.html', settings_context) - elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): + elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): # pylint: disable=too-many-nested-blocks if request.method == 'GET': course_details = CourseDetails.fetch(course_key) return JsonResponse( @@ -1252,9 +1254,17 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab set_prerequisite_courses(course_key, prerequisite_course_keys) else: # None is chosen, so remove the course prerequisites - course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="requires") # lint-amnesty, pylint: disable=line-too-long + course_milestones = milestones_api.get_course_milestones( + course_key=course_key, + relationship="requires", + ) for milestone in course_milestones: - remove_prerequisite_course(course_key, milestone) + entrance_exam_namespace = generate_milestone_namespace( + get_namespace_choices().get('ENTRANCE_EXAM'), + course_key + ) + if milestone["namespace"] != entrance_exam_namespace: + remove_prerequisite_course(course_key, milestone) # If the entrance exams feature has been enabled, we'll need to check for some # feature-specific settings and handle them accordingly diff --git a/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py b/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py index 11f540193bd4..4d98679c7cd4 100644 --- a/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py +++ b/common/djangoapps/student/management/tests/test_bulk_change_enrollment.py @@ -27,7 +27,7 @@ def setUp(self): self.users = UserFactory.create_batch(5) CourseOverview.load_from_module_store(self.course.id) - @patch('common.djangoapps.student.models.tracker') + @patch('common.djangoapps.student.models.course_enrollment.tracker') @ddt.data(('audit', 'honor'), ('honor', 'audit')) @ddt.unpack def test_bulk_convert(self, from_mode, to_mode, mock_tracker): @@ -55,7 +55,7 @@ def test_bulk_convert(self, from_mode, to_mode, mock_tracker): CourseEnrollment.objects.get(mode=to_mode, course_id=self.course.id, user=user) self._assert_mode_changed(mock_tracker, self.course, user, to_mode) - @patch('common.djangoapps.student.models.tracker') + @patch('common.djangoapps.student.models.course_enrollment.tracker') @ddt.data(('audit', 'no-id-professional'), ('no-id-professional', 'audit')) @ddt.unpack def test_bulk_convert_with_org(self, from_mode, to_mode, mock_tracker): @@ -108,7 +108,7 @@ def test_with_org_and_course_key(self): assert 'Error: argument -o/--org: not allowed with argument -c/--course' == str(err.value) - @patch('common.djangoapps.student.models.tracker') + @patch('common.djangoapps.student.models.course_enrollment.tracker') def test_with_org_and_invalid_to_mode(self, mock_tracker): """Verify that enrollments are changed correctly when org was given.""" from_mode = 'audit' diff --git a/common/djangoapps/student/management/tests/test_transfer_students.py b/common/djangoapps/student/management/tests/test_transfer_students.py index f42ab66e10ad..5cdf32f8bd1f 100644 --- a/common/djangoapps/student/management/tests/test_transfer_students.py +++ b/common/djangoapps/student/management/tests/test_transfer_students.py @@ -41,7 +41,7 @@ def setUp(self, **kwargs): # lint-amnesty, pylint: disable=unused-argument super().setUp() UNENROLL_DONE.connect(self.assert_unenroll_signal) - patcher = patch('common.djangoapps.student.models.tracker') + patcher = patch('common.djangoapps.student.models.course_enrollment.tracker') self.mock_tracker = patcher.start() self.addCleanup(patcher.stop) self.addCleanup(UNENROLL_DONE.disconnect, self.assert_unenroll_signal) diff --git a/common/djangoapps/student/models/__init__.py b/common/djangoapps/student/models/__init__.py new file mode 100644 index 000000000000..951ff547bc07 --- /dev/null +++ b/common/djangoapps/student/models/__init__.py @@ -0,0 +1,5 @@ +''' +Student models migrated to folder to tease out the course enrollment aspects from Student +''' +from .course_enrollment import * +from .student import * diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models/course_enrollment.py similarity index 50% rename from common/djangoapps/student/models.py rename to common/djangoapps/student/models/course_enrollment.py index 673e170072b9..6f78edd2c0a0 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -1,1117 +1,108 @@ -""" -Models for User Information (students, staff, etc) - -Migration Notes - -If you make changes to this model, be sure to create an appropriate migration -file and check it in at the same time as your model changes. To do that, - -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/ -""" +"""Models for student course enrollment""" +import crum import hashlib # lint-amnesty, pylint: disable=wrong-import-order -import json # lint-amnesty, pylint: disable=wrong-import-order import logging # lint-amnesty, pylint: disable=wrong-import-order import uuid # lint-amnesty, pylint: disable=wrong-import-order from collections import defaultdict, namedtuple # lint-amnesty, pylint: disable=wrong-import-order -from datetime import date, datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order -from functools import total_ordering # lint-amnesty, pylint: disable=wrong-import-order -from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order -from urllib.parse import unquote, urlencode, urljoin - -import crum +from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price +from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, UNENROLL_DONE +from common.djangoapps.track import contexts, segment +from common.djangoapps.util.query import use_read_replica_if_available from config_models.models import ConfigurationModel -from django.apps import apps +from datetime import date, datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user -from django.contrib.auth.signals import user_logged_in, user_logged_out -from django.contrib.sites.models import Site from django.core.cache import cache from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.core.validators import FileExtensionValidator, RegexValidator -from django.db import IntegrityError, models +from django.core.validators import FileExtensionValidator +from django.db import models from django.db.models import Count, Index, Q -from django.db.models.signals import post_save, pre_save -from django.db.utils import ProgrammingError from django.dispatch import receiver from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from django.utils.translation import gettext_noop -from django_countries.fields import CountryField -from edx_django_utils import monitoring from edx_django_utils.cache import RequestCache, TieredCache, get_cache_key from eventtracking import tracker -from model_utils.models import TimeStampedModel -from opaque_keys.edx.django.models import CourseKeyField, LearningContextKeyField -from opaque_keys.edx.keys import CourseKey -from openedx_events.learning.data import CourseData, CourseEnrollmentData, UserData, UserPersonalData -from openedx_events.learning.signals import ( - COURSE_ENROLLMENT_CHANGED, - COURSE_ENROLLMENT_CREATED, - COURSE_UNENROLLMENT_COMPLETED, -) -from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted -from pytz import UTC, timezone -from requests.exceptions import HTTPError, RequestException -from simple_history.models import HistoricalRecords -from user_util import user_util - -import openedx.core.djangoapps.django_comment_common.comment_client as cc -from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price -from common.djangoapps.student.email_helpers import ( - generate_proctoring_requirements_email_context, - should_send_proctoring_requirements_email, -) -from common.djangoapps.student.emails import send_proctoring_requirements_email -from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, UNENROLL_DONE -from common.djangoapps.track import contexts, segment -from common.djangoapps.util.model_utils import emit_field_changed_events, get_changed_fields_dict -from common.djangoapps.util.query import use_read_replica_if_available +from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.courseware.models import ( CourseDynamicUpgradeDeadlineConfiguration, DynamicUpgradeDeadlineConfiguration, OrgDynamicUpgradeDeadlineConfiguration, ) -from lms.djangoapps.courseware.toggles import streak_celebration_is_active +from lms.djangoapps.utils import OptimizelyClient from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from model_utils.models import TimeStampedModel +from opaque_keys.edx.django.models import CourseKeyField +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import ( _default_course_mode, get_enrollment_attributes, set_enrollment_attributes, ) -from openedx.core.djangoapps.signals.signals import USER_ACCOUNT_ACTIVATED +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.core.djangolib.model_mixins import DeletableByUserValue -from openedx.core.toggles import ENTRANCE_EXAMS +from openedx_events.learning.data import CourseData, CourseEnrollmentData, UserData, UserPersonalData +from openedx_events.learning.signals import ( + COURSE_ENROLLMENT_CHANGED, + COURSE_ENROLLMENT_CREATED, + COURSE_UNENROLLMENT_COMPLETED, +) +from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted +from pytz import UTC +from requests.exceptions import HTTPError, RequestException +from simple_history.models import HistoricalRecords +from urllib.parse import urljoin log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # pylint: disable=invalid-name -# ENROLL signal used for free enrollment only -class EnrollStatusChange: - """ - 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' - -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' -SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE = 30 - -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) -) -IS_MARKETABLE = 'is_marketable' - - -class AnonymousUserId(models.Model): - """ - This table contains user, course_Id and anonymous_user_id - - Purpose of this table is to provide user by anonymous_user_id. - - We generate anonymous_user_id using md5 algorithm, - and use result in hex form, so its length is equal to 32 bytes. - - .. no_pii: We store anonymous_user_ids here, but do not consider them PII under OEP-30. - """ - - objects = NoneToEmptyManager() - - user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) - anonymous_user_id = models.CharField(unique=True, max_length=32) - course_id = LearningContextKeyField(db_index=True, max_length=255, blank=True) - - -def anonymous_id_for_user(user, course_id): - """ - Inputs: - user: User model - course_id: string or None - - Return a unique id for a (user, course_id) pair, suitable for inserting - into e.g. personalized survey links. - - If user is an `AnonymousUser`, returns `None` - else If this user/course_id pair already has an anonymous id in AnonymousUserId object, return that - else: create new anonymous_id, save it in AnonymousUserId, and return anonymous id - """ - - # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated. - assert user - - if user.is_anonymous: - return None - - # ARCHBOM-1674: Get a sense of what fraction of anonymous_user_id calls are - # cached, stored in the DB, or retrieved from the DB. This will help inform - # us on decisions about whether we can - # pregenerate IDs, use random instead of deterministic IDs, etc. - monitoring.increment('temp_anon_uid_v2.requested') - - cached_id = getattr(user, '_anonymous_id', {}).get(course_id) - if cached_id is not None: - monitoring.increment('temp_anon_uid_v2.returned_from_cache') - return cached_id - # Check if an anonymous id already exists for this user and - # course_id combination. Prefer the one with the highest record ID - # (see below.) - anonymous_user_ids = AnonymousUserId.objects.filter(user=user).filter(course_id=course_id).order_by('-id') - if anonymous_user_ids: - # If there are multiple anonymous_user_ids per user, course_id pair - # select the row which was created most recently. - # There might be more than one if the Django SECRET_KEY had - # previously been rotated at a time before this function was - # changed to always save the generated IDs to the DB. In that - # case, just pick the one with the highest record ID, which is - # probably the most recently created one. - anonymous_user_id = anonymous_user_ids[0].anonymous_user_id - monitoring.increment('temp_anon_uid_v2.fetched_existing') - else: - # Uses SECRET_KEY as a cryptographic pepper. This - # deterministic ID generation means that concurrent identical - # calls to this function return the same value -- no need for - # locking. (There may be a low level of integrity errors on - # creation as a result of concurrent duplicate row inserts.) - # - # Consequences for this function of SECRET_KEY exposure: Data - # researchers and other third parties receiving these - # anonymous user IDs would be able to identify users across - # courses, and predict the anonymous user IDs of all users - # (but not necessarily identify their accounts.) - # - # Rotation process of SECRET_KEY with respect to this - # function: Rotate at will, since the hashes are stored and - # will not change. - # include the secret key as a salt, and to make the ids unique across different LMS installs. - hasher = hashlib.shake_128() - hasher.update(settings.SECRET_KEY.encode('utf8')) - hasher.update(str(user.id).encode('utf8')) - if course_id: - hasher.update(str(course_id).encode('utf-8')) - anonymous_user_id = hasher.hexdigest(16) # pylint: disable=too-many-function-args - - try: - AnonymousUserId.objects.create( - user=user, - course_id=course_id, - anonymous_user_id=anonymous_user_id, - ) - monitoring.increment('temp_anon_uid_v2.stored') - except IntegrityError: - # Another thread has already created this entry, so - # continue - monitoring.increment('temp_anon_uid_v2.store_db_error') - - # cache the anonymous_id in the user object - if not hasattr(user, '_anonymous_id'): - user._anonymous_id = {} # pylint: disable=protected-access - user._anonymous_id[course_id] = anonymous_user_id # pylint: disable=protected-access - - return anonymous_user_id - - -def user_by_anonymous_id(uid): - """ - Return user by anonymous_user_id using AnonymousUserId lookup table. - - Do not raise `django.ObjectDoesNotExist` exception, - if there is no user for anonymous_student_id, - because this function will be used inside xmodule w/o django access. - """ - - if uid is None: - return None - - request_cache = RequestCache('user_by_anonymous_id') - cache_response = request_cache.get_cached_response(uid) - if cache_response.is_found: - return cache_response.value - - try: - user = User.objects.get(anonymoususerid__anonymous_user_id=uid) - request_cache.set(uid, user) - return user - except ObjectDoesNotExist: - request_cache.set(uid, None) - return None - - -def is_username_retired(username): - """ - Checks to see if the given username has been previously retired - """ - locally_hashed_usernames = user_util.get_all_retired_usernames( - username, - settings.RETIRED_USER_SALTS, - settings.RETIRED_USERNAME_FMT - ) - - # TODO: Revert to this after username capitalization issues detailed in - # PLAT-2276, PLAT-2277, PLAT-2278 are sorted out: - # return User.objects.filter(username__in=list(locally_hashed_usernames)).exists() - - # Avoid circular import issues - from openedx.core.djangoapps.user_api.models import UserRetirementStatus - - # Sandbox clean builds attempt to create users during migrations, before the database - # is stable so UserRetirementStatus may not exist yet. This workaround can also go - # when we are done with the username updates. - try: - return User.objects.filter(username__in=list(locally_hashed_usernames)).exists() or \ - UserRetirementStatus.objects.filter(original_username=username).exists() - except ProgrammingError as exc: - # Check the error message to make sure it's what we expect - if "user_api_userretirementstatus" in str(exc): - return User.objects.filter(username__in=list(locally_hashed_usernames)).exists() - raise - - -def username_exists_or_retired(username): - """ - Check a username for existence -or- retirement against the User model. - """ - return User.objects.filter(username=username).exists() or is_username_retired(username) - - -def is_email_retired(email): - """ - Checks to see if the given email has been previously retired - """ - locally_hashed_emails = user_util.get_all_retired_emails( - email, - settings.RETIRED_USER_SALTS, - settings.RETIRED_EMAIL_FMT - ) - - return User.objects.filter(email__in=list(locally_hashed_emails)).exists() - - -def email_exists_or_retired(email): - """ - Check an email against the User model for existence. - """ - return ( - User.objects.filter(email=email).exists() or - is_email_retired(email) or - AccountRecovery.objects.filter(secondary_email=email).exists() - ) - - -def get_retired_username_by_username(username): - """ - If a UserRetirementStatus object with an original_username matching the given username exists, - returns that UserRetirementStatus.retired_username value. Otherwise, returns a "retired username" - hashed using the newest configured salt. - """ - UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus') - try: - status = UserRetirementStatus.objects.filter(original_username=username).order_by('-modified').first() - if status: - return status.retired_username - except UserRetirementStatus.DoesNotExist: - pass - return user_util.get_retired_username(username, settings.RETIRED_USER_SALTS, settings.RETIRED_USERNAME_FMT) - - -def get_retired_email_by_email(email): - """ - If a UserRetirementStatus object with an original_email matching the given email exists, - returns that UserRetirementStatus.retired_email value. Otherwise, returns a "retired email" - hashed using the newest configured salt. - """ - UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus') - try: - status = UserRetirementStatus.objects.filter(original_email=email).order_by('-modified').first() - if status: - return status.retired_email - except UserRetirementStatus.DoesNotExist: - pass - return user_util.get_retired_email(email, settings.RETIRED_USER_SALTS, settings.RETIRED_EMAIL_FMT) - - -def _get_all_retired_usernames_by_username(username): - """ - Returns a generator of "retired usernames", one hashed with each - configured salt. Used for finding out if the given username has - ever been used and retired. - """ - return user_util.get_all_retired_usernames(username, settings.RETIRED_USER_SALTS, settings.RETIRED_USERNAME_FMT) - - -def _get_all_retired_emails_by_email(email): - """ - Returns a generator of "retired emails", one hashed with each - configured salt. Used for finding out if the given email has - ever been used and retired. - """ - return user_util.get_all_retired_emails(email, settings.RETIRED_USER_SALTS, settings.RETIRED_EMAIL_FMT) - - -def get_potentially_retired_user_by_username(username): - """ - Attempt to return a User object based on the username, or if it - does not exist, then any hashed username salted with the historical - salts. - """ - locally_hashed_usernames = list(_get_all_retired_usernames_by_username(username)) - locally_hashed_usernames.append(username) - potential_users = User.objects.filter(username__in=locally_hashed_usernames) - - # Have to disambiguate between several Users here as we could have retirees with - # the same username, but for case. - # If there's only 1 we're done, this should be the common case - if len(potential_users) == 1: - return potential_users[0] - - # No user found, throw the usual error - if not potential_users: - raise User.DoesNotExist() - - # For a brief period, users were able to retire accounts and make another account with - # the same differently-cased username, like "testuser" and "TestUser". - # If there are two users found, return the one that's the *actual* case-matching username, - # whether retired or not. - if len(potential_users) == 2: - # Figure out which user has been retired. - if potential_users[0].username.startswith(settings.RETIRED_USERNAME_PREFIX): - retired = potential_users[0] - active = potential_users[1] - else: - retired = potential_users[1] - active = potential_users[0] - - # If the active (non-retired) user's username doesn't *exactly* match (including case), - # then the retired account must be the one that exactly matches. - return active if active.username == username else retired - - # We should have, at most, a retired username and an active one with a username - # differing only by case. If there are more we need to disambiguate them by hand. - raise Exception(f'Expected 1 or 2 Users, received {str(potential_users)}') - - -def get_potentially_retired_user_by_username_and_hash(username, hashed_username): - """ - To assist in the retirement process this method will: - - Confirm that any locally hashed username matches the passed in one - (in case of salt mismatches with the upstream script). - - Attempt to return a User object based on the username, or if it - does not exist, the any hashed username salted with the historical - salts. - """ - locally_hashed_usernames = list(_get_all_retired_usernames_by_username(username)) - - if hashed_username not in locally_hashed_usernames: - raise Exception('Mismatched hashed_username, bad salt?') - - locally_hashed_usernames.append(username) - return User.objects.get(username__in=locally_hashed_usernames) - - -def is_personalized_recommendation_for_user(course_id): - """ - Returns the personalized recommendation value from the cookie. - """ - request = crum.get_current_request() - recommended_courses = \ - request.COOKIES.get(settings.PERSONALIZED_RECOMMENDATION_COOKIE_NAME, None) if request else None - - if recommended_courses: - recommended_courses = json.loads(unquote(recommended_courses)) - if course_id in recommended_courses['course_keys']: - return recommended_courses['is_personalized_recommendation'] - return None - - -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. - - .. no_pii: - """ - ACCOUNT_DISABLED = "disabled" - ACCOUNT_ENABLED = "enabled" - USER_STANDING_CHOICES = ( - (ACCOUNT_DISABLED, "Account Disabled"), - (ACCOUNT_ENABLED, "Account Enabled"), - ) - - user = models.OneToOneField(User, db_index=True, related_name='standing', on_delete=models.CASCADE) - account_status = models.CharField( - blank=True, max_length=31, choices=USER_STANDING_CHOICES - ) - changed_by = models.ForeignKey(User, blank=True, on_delete=models.CASCADE) - standing_last_changed_at = models.DateTimeField(auto_now=True) - - -class UserProfile(models.Model): - """This is where we store all the user demographic fields. We have a - separate table for this rather than extending the built-in Django auth_user. - - Notes: - * Some fields are legacy ones from the first run of 6.002, from which - we imported many users. - * Fields like name and address are intentionally open ended, to account - for international variations. An unfortunate side-effect is that we - cannot efficiently sort on last names for instance. - - Replication: - * Only the Portal servers should ever modify this information. - * All fields are replicated into relevant Course databases - - Some of the fields are legacy ones that were captured during the initial - MITx fall prototype. - - .. pii: Contains many PII fields. Retired in AccountRetirementView. - .. pii_types: name, location, birth_date, gender, biography, phone_number - .. pii_retirement: local_api - """ - # cache key format e.g user..profile.country = 'SG' - PROFILE_COUNTRY_CACHE_KEY = "user.{user_id}.profile.country" - - class Meta: - db_table = "auth_userprofile" - permissions = (("can_deactivate_users", "Can deactivate, but NOT delete users"),) - - # CRITICAL TODO/SECURITY - # Sanitize all fields. - # This is not visible to other users, but could introduce holes later - user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile', on_delete=models.CASCADE) - name = models.CharField(blank=True, max_length=255, db_index=True) - - # How meta field works: meta will only store those fields which are available in extended_profile configuration, - # so in order to store a field in meta, it must be available in extended_profile configuration. - meta = models.TextField(blank=True) # JSON dictionary for future expansion - courseware = models.CharField(blank=True, max_length=255, default='course.xml') - - # Language is deprecated and no longer used. Old rows exist that have - # user-entered free form text values (ex. "English"), some of which have - # non-ASCII values. You probably want UserPreference version of this, which - # stores the user's preferred language code. See openedx/core/djangoapps/lang_pref - # for more information. - language = models.CharField(blank=True, max_length=255, db_index=True) - - # Location is no longer used, but is held here for backwards compatibility - # for users imported from our first class. - location = models.CharField(blank=True, max_length=255, db_index=True) - - # Optional demographic data we started capturing from Fall 2012 - this_year = datetime.now(UTC).year - VALID_YEARS = list(range(this_year, this_year - 120, -1)) - year_of_birth = models.IntegerField(blank=True, null=True, db_index=True) - GENDER_CHOICES = ( - ('m', gettext_noop('Male')), - ('f', gettext_noop('Female')), - # Translators: 'Other' refers to the student's gender - ('o', gettext_noop('Other/Prefer Not to Say')) - ) - gender = models.CharField( - blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES - ) - - # [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'), - LEVEL_OF_EDUCATION_CHOICES = ( - ('p', gettext_noop('Doctorate')), - ('m', gettext_noop("Master's or professional degree")), - ('b', gettext_noop("Bachelor's degree")), - ('a', gettext_noop("Associate degree")), - ('hs', gettext_noop("Secondary/high school")), - ('jhs', gettext_noop("Junior secondary/junior high/middle school")), - ('el', gettext_noop("Elementary/primary school")), - # Translators: 'None' refers to the student's level of education - ('none', gettext_noop("No formal education")), - # Translators: 'Other' refers to the student's level of education - ('other', gettext_noop("Other education")) - ) - level_of_education = models.CharField( - blank=True, null=True, max_length=6, db_index=True, - choices=LEVEL_OF_EDUCATION_CHOICES - ) - mailing_address = models.TextField(blank=True, null=True) - city = models.TextField(blank=True, null=True) - country = CountryField(blank=True, null=True) - COUNTRY_WITH_STATES = 'US' - STATE_CHOICES = ( - ('AL', 'Alabama'), - ('AK', 'Alaska'), - ('AZ', 'Arizona'), - ('AR', 'Arkansas'), - ('AA', 'Armed Forces Americas'), - ('AE', 'Armed Forces Europe'), - ('AP', 'Armed Forces Pacific'), - ('CA', 'California'), - ('CO', 'Colorado'), - ('CT', 'Connecticut'), - ('DE', 'Delaware'), - ('DC', 'District Of Columbia'), - ('FL', 'Florida'), - ('GA', 'Georgia'), - ('HI', 'Hawaii'), - ('ID', 'Idaho'), - ('IL', 'Illinois'), - ('IN', 'Indiana'), - ('IA', 'Iowa'), - ('KS', 'Kansas'), - ('KY', 'Kentucky'), - ('LA', 'Louisiana'), - ('ME', 'Maine'), - ('MD', 'Maryland'), - ('MA', 'Massachusetts'), - ('MI', 'Michigan'), - ('MN', 'Minnesota'), - ('MS', 'Mississippi'), - ('MO', 'Missouri'), - ('MT', 'Montana'), - ('NE', 'Nebraska'), - ('NV', 'Nevada'), - ('NH', 'New Hampshire'), - ('NJ', 'New Jersey'), - ('NM', 'New Mexico'), - ('NY', 'New York'), - ('NC', 'North Carolina'), - ('ND', 'North Dakota'), - ('OH', 'Ohio'), - ('OK', 'Oklahoma'), - ('OR', 'Oregon'), - ('PA', 'Pennsylvania'), - ('RI', 'Rhode Island'), - ('SC', 'South Carolina'), - ('SD', 'South Dakota'), - ('TN', 'Tennessee'), - ('TX', 'Texas'), - ('UT', 'Utah'), - ('VT', 'Vermont'), - ('VA', 'Virginia'), - ('WA', 'Washington'), - ('WV', 'West Virginia'), - ('WI', 'Wisconsin'), - ('WY', 'Wyoming'), - ) - state = models.CharField(blank=True, null=True, max_length=2, choices=STATE_CHOICES) - goals = models.TextField(blank=True, null=True) - bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False) - profile_image_uploaded_at = models.DateTimeField(null=True, blank=True) - phone_regex = RegexValidator(regex=r'^\+?1?\d*$', message="Phone number can only contain numbers.") - phone_number = models.CharField(validators=[phone_regex], blank=True, null=True, max_length=50) - - @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 - - @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: - return self._calculate_age(year, year_of_birth) - - @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) - - def get_meta(self): # pylint: disable=missing-function-docstring - js_str = self.meta - if not js_str: - js_str = {} - else: - js_str = json.loads(self.meta) - - return js_str - - def set_meta(self, meta_json): - self.meta = json.dumps(meta_json) - - 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() - - def requires_parental_consent(self, year=None, age_limit=None, default_requires_consent=True): - """Returns true if this user requires parental consent. - - Args: - year (int): The year 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 - - if year is None: - age = self.age - else: - age = self._calculate_age(year, year_of_birth) - - return age < age_limit - - 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] - - 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 - - @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 - """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) - - -@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: - user_profile.profile_image_uploaded_at = None - - # 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) # lint-amnesty, pylint: disable=protected-access - - -@receiver(post_save, sender=UserProfile) -def user_profile_post_save_callback(sender, **kwargs): - """ - Emit analytics events after saving the UserProfile. - """ - user_profile = kwargs['instance'] - emit_field_changed_events( - user_profile, - user_profile.user, - sender._meta.db_table, - excluded_fields=['meta'] - ) - - -@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) # lint-amnesty, pylint: disable=protected-access - - -@receiver(post_save, sender=User) -def user_post_save_callback(sender, **kwargs): - """ - When a user is modified and either its `is_active` state or email address - is changed, and the user is, in fact, active, then check to see if there - are any courses that it needs to be automatically enrolled in and enroll them if needed. - - Additionally, emit analytics events after saving the User. - """ - user = kwargs['instance'] - - changed_fields = user._changed_fields # lint-amnesty, pylint: disable=protected-access - - if 'is_active' in changed_fields or 'email' in changed_fields: - if user.is_active: - ceas = CourseEnrollmentAllowed.for_user(user).filter(auto_enroll=True) - - for cea in ceas: - # skip enrolling already enrolled users - if CourseEnrollment.is_enrolled(user, cea.course_id): - # Link the CEA to the user if the CEA isn't already linked to the user - # (e.g. the user was invited to a course but hadn't activated the account yet) - # This is to prevent students from changing e-mails and - # enrolling many accounts through the same e-mail. - if not cea.user: - cea.user = user - cea.save() - continue - - enrollment = CourseEnrollment.enroll(user, cea.course_id) - - manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(user.email) - if manual_enrollment_audit is not None: - # get the enrolled by user and reason from the ManualEnrollmentAudit table. - # then create a new ManualEnrollmentAudit table entry for the same email - # different transition state. - ManualEnrollmentAudit.create_manual_enrollment_audit( - manual_enrollment_audit.enrolled_by, - user.email, - ALLOWEDTOENROLL_TO_ENROLLED, - manual_enrollment_audit.reason, - enrollment - ) - - # Ensure the user has a profile when run via management command - _called_by_management_command = getattr(user, '_called_by_management_command', None) - if _called_by_management_command: - try: - profile = user.profile - except UserProfile.DoesNotExist: - profile = UserProfile.objects.create(user=user) - log.info('Created new profile for user: %s', user) - - # If user is created using management command, ensure that the user's - # marketable attribute is set (default: False) and an account is created - # on segment. By created an account on segment, it is ensured that data - # will be sent to relevant places like Braze. - if settings.MARKETING_EMAILS_OPT_IN: - UserAttribute.set_user_attribute(user, IS_MARKETABLE, 'false') - - traits = { - 'email': user.email, - 'username': user.username, - 'name': profile.name, - 'age': profile.age or -1, - 'yearOfBirth': profile.year_of_birth or datetime.now(UTC).year, - 'education': profile.level_of_education_display, - 'address': profile.mailing_address, - 'gender': profile.gender_display, - 'country': str(profile.country), - 'is_marketable': False - } - # .. pii: Many pieces of PII are sent to Segment here. Retired directly through Segment API call in Tubular. - # .. pii_types: email_address, username - # .. pii_retirement: third_party - segment.identify(user.id, traits) - - # Because `emit_field_changed_events` removes the record of the fields that - # were changed, wait to do that until after we've checked them as part of - # the condition on whether we want to check for automatic enrollments. - emit_field_changed_events( - user, - user, - sender._meta.db_table, - excluded_fields=['last_login', 'first_name', 'last_name'], - hidden_fields=['password'] - ) - - -class UserSignupSource(models.Model): - """ - This table contains information about users registering - via Micro-Sites - - .. no_pii: - """ - user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) - site = models.CharField(max_length=255, db_index=True) - - -def unique_id_for_user(user): - """ - Return a unique id for a user, suitable for inserting into - e.g. personalized survey links. - """ - # Setting course_id to '' makes it not affect the generated hash, - # and thus produce the old per-student anonymous id - return anonymous_id_for_user(user, None) - - -# TODO: Should be renamed to generic UserGroup, and possibly -# Given an optional field for type of group -class UserTestGroup(models.Model): - """ - .. no_pii: - """ - users = models.ManyToManyField(User, db_index=True) - name = models.CharField(blank=False, max_length=32, db_index=True) - description = models.TextField(blank=True) - - -class Registration(models.Model): - """ - Allows us to wait for e-mail before user is registered. A - registration profile is created when the user creates an - account, but that account is inactive. Once the user clicks - on the activation key, it becomes active. - - .. no_pii: - """ - - class Meta: - db_table = "auth_registration" - - user = models.OneToOneField(User, on_delete=models.CASCADE) - activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) - activation_timestamp = models.DateTimeField(default=None, null=True, blank=True) - - def register(self, user): - # MINOR TODO: Switch to crypto-secure key - self.activation_key = uuid.uuid4().hex - self.user = user - self.save() - - def activate(self): # lint-amnesty, pylint: disable=missing-function-docstring - self.user.is_active = True - self.user.save(update_fields=['is_active']) - self.activation_timestamp = datetime.utcnow() - self.save() - USER_ACCOUNT_ACTIVATED.send_robust(self.__class__, user=self.user) - log.info('User %s (%s) account is successfully activated.', self.user.username, self.user.email) - - -class PendingNameChange(DeletableByUserValue, models.Model): - """ - This model keeps track of pending requested changes to a user's name. - - .. pii: Contains new_name, retired in LMSAccountRetirementView - .. pii_types: name - .. pii_retirement: local_api - """ - user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE) - new_name = models.CharField(blank=True, max_length=255) - rationale = models.CharField(blank=True, max_length=1024) - - -class PendingEmailChange(DeletableByUserValue, models.Model): - """ - This model keeps track of pending requested changes to a user's email address. - - .. pii: Contains new_email, retired in AccountRetirementView - .. pii_types: email_address - .. pii_retirement: local_api - """ - user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE) - new_email = models.CharField(blank=True, max_length=255, db_index=True) - activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) - - 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 - - -class PendingSecondaryEmailChange(DeletableByUserValue, models.Model): - """ - This model keeps track of pending requested changes to a user's secondary email address. - - .. pii: Contains new_secondary_email, not currently retired - .. pii_types: email_address - .. pii_retirement: retained - """ - user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE) - new_secondary_email = models.CharField(blank=True, max_length=255, db_index=True) - activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) - - -EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' -EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' -EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed' - - -class LoginFailures(models.Model): - """ - This model will keep track of failed login attempts. - - .. no_pii: - """ - user = models.ForeignKey(User, on_delete=models.CASCADE) - failure_count = models.IntegerField(default=0) - lockout_until = models.DateTimeField(null=True) - - @classmethod - 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 - def is_feature_enabled(cls): - """ - Returns whether the feature flag around this functionality has been set - """ - return settings.FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] - - @classmethod - def is_user_locked_out(cls, user): - """ - Static method to return in a given user has his/her account locked out - """ - try: - record = cls._get_record_for_user(user) - if not record.lockout_until: - return False - - now = datetime.now(UTC) - until = record.lockout_until - is_locked_out = until and now < until - - return is_locked_out - except ObjectDoesNotExist: - return False - - @classmethod - def increment_lockout_counter(cls, user): - """ - Ticks the failed attempt counter - """ - record, _ = LoginFailures.objects.get_or_create(user=user) - record.failure_count = record.failure_count + 1 - max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED - - # did we go over the limit in attempts - if record.failure_count >= max_failures_allowed: - # yes, then store when this account is locked out until - lockout_period_secs = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS - record.lockout_until = datetime.now(UTC) + timedelta(seconds=lockout_period_secs) - - record.save() - - @classmethod - def check_user_reset_password_threshold(cls, user): - """ - Checks if the user is above threshold for reset password message. - """ - record, _ = LoginFailures.objects.get_or_create(user=user) - max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED - - return record.failure_count >= max_failures_allowed / 2, record.failure_count +# ENROLL signal used for free enrollment only +class EnrollStatusChange: + """ + 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' - @classmethod - def clear_lockout_counter(cls, user): - """ - Removes the lockout counters (normally called after a successful login) - """ - try: - entry = cls._get_record_for_user(user) - entry.delete() - except ObjectDoesNotExist: - return +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' +SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE = 30 - def __str__(self): - """Str -> Username: count - date.""" - return '{username}: {count} - {date}'.format( - username=self.user.username, - count=self.failure_count, - date=self.lockout_until.isoformat() if self.lockout_until else '-' - ) +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) +) - class Meta: - verbose_name = 'Login Failure' - verbose_name_plural = 'Login Failures' +EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' +EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' +EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed' class CourseEnrollmentException(Exception): @@ -1424,7 +415,7 @@ def is_enrollment_closed(cls, user, course): from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE return not user.has_perm(ENROLL_IN_COURSE, course) - def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterprise_uuid=None): + def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterprise_uuid=None, request=None): """ Updates an enrollment for a user in a class. This includes options like changing the mode, toggling is_active True/False, etc. @@ -1489,10 +480,10 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterp if activation_changed: if self.is_active: - self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED, enterprise_uuid=enterprise_uuid) + self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED, enterprise_uuid=enterprise_uuid, request=request) else: UNENROLL_DONE.send(sender=None, course_enrollment=self, skip_refund=skip_refund) - self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED, enterprise_uuid=enterprise_uuid) + self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED, enterprise_uuid=enterprise_uuid, request=request) self.send_signal(EnrollStatusChange.unenroll) # .. event_implemented_name: COURSE_UNENROLLMENT_COMPLETED @@ -1515,6 +506,12 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterp ) if mode_changed: + from common.djangoapps.student.email_helpers import ( + generate_proctoring_requirements_email_context, + should_send_proctoring_requirements_email, + ) + from common.djangoapps.student.emails import send_proctoring_requirements_email + # If mode changed to one that requires proctoring, send proctoring requirements email if should_send_proctoring_requirements_email(self.user.username, self.course_id): email_context = generate_proctoring_requirements_email_context(self.user, self.course_id) @@ -1552,11 +549,20 @@ def send_signal_full(cls, event, user=user, mode=mode, course_id=None, cost=None mode=mode, course_id=course_id, cost=cost, currency=currency) - def emit_event(self, event_name, enterprise_uuid=None): + def emit_event(self, event_name, enterprise_uuid=None, request=None): # pylint: disable=too-many-statements """ Emits an event to explicitly track course enrollment and unenrollment. """ + from common.djangoapps.student.helpers import get_course_dates_for_email, get_instructors + from common.djangoapps.student.toggles import should_send_redesign_email from openedx.core.djangoapps.schedules.config import set_up_external_updates_for_enrollment + from openedx.core.djangoapps.catalog.api import get_course_run_details + from openedx.core.djangoapps.catalog.utils import get_owners_for_course, get_course_uuid_for_course + from openedx.features.course_experience import ENABLE_COURSE_GOALS + from openedx.core.djangoapps.user_api.preferences.api import get_user_preference + from openedx.features.enterprise_support.utils import is_enterprise_learner + + optimizely_client = OptimizelyClient.get_optimizely_client() segment_properties = { 'category': 'conversion', @@ -1595,6 +601,64 @@ def emit_event(self, event_name, enterprise_uuid=None): segment_traits['email'] = self.user.email if event_name == EVENT_NAME_ENROLLMENT_ACTIVATED: + studio_request = settings.ROOT_URLCONF == 'cms.urls' + extra_segment_properties = { + 'studio_request': studio_request + } + exception_raised = False + if not studio_request and should_send_redesign_email(): + if not request: + request = crum.get_current_request() + + marketing_root_url = settings.MKTG_URLS.get('ROOT') + course_run_fields = [ + 'key', 'title', 'short_description', 'marketing_url', 'pacing_type', 'min_effort', + 'max_effort', 'weeks_to_complete', 'enrollment_count', 'image', 'staff', + ] + owners, course_run, course_dates_list = None, None, [] + try: + course_dates_list = get_course_dates_for_email(self.user, self.course.id, request) + course_uuid = get_course_uuid_for_course(str(self.course_id)) + owners = get_owners_for_course(course_uuid=course_uuid) + course_run = get_course_run_details(str(self.course_id), course_run_fields) + except Exception: # pylint: disable=broad-except + exception_raised = True + log.exception( + 'Unable to send extra properties for %s event, user %s and course %s', + event_name, + self.user.id, + self.course_id, + ) + + if course_run: + instructors = get_instructors(course_run, marketing_root_url) + extra_segment_properties.update({ + 'instructors': instructors, + 'instructors_count': 'even' if len(instructors) % 2 == 0 else 'odd', + 'pacing_type': course_run.get('pacing_type'), + 'min_effort': course_run.get('min_effort'), + 'max_effort': course_run.get('max_effort'), + 'weeks_to_complete': course_run.get('weeks_to_complete'), + 'learners_count': '{:,}'.format(course_run.get('enrollment_count')), + 'course_title': course_run.get('title'), + 'short_description': course_run.get('short_description'), + 'marketing_url': course_run.get('marketing_url'), + 'banner_image_url': course_run.get('image').get('src') if course_run.get('image') else '' + }) + price = CourseMode.min_course_price_for_currency(course_id=str(self.course_id), currency='USD') + extra_segment_properties.update({ + 'goals_enabled': ENABLE_COURSE_GOALS.is_enabled(self.course_id), + 'course_date_blocks': course_dates_list, + 'partner_image_url': owners[0].get('logo_image_url') if owners else '', + 'learner_name': self.user.profile.name, + 'course_run_key': str(self.course_id), + 'course_price': price, + 'lms_base_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), + 'learning_base_url': configuration_helpers.get_value('LEARNING_MICROFRONTEND_URL', + settings.LEARNING_MICROFRONTEND_URL) + }) + segment_properties.update(extra_segment_properties) + segment_properties['exception_raised'] = exception_raised segment_properties['email'] = self.user.email # This next property is for an experiment, see method's comments for more information segment_properties['external_course_updates'] = set_up_external_updates_for_enrollment(self.user, @@ -1603,10 +667,31 @@ def emit_event(self, event_name, enterprise_uuid=None): segment_properties['course_pacing'] = self.course.pacing course_key = f'{self.course_id.org}+{self.course_id.course}' + from .student import is_personalized_recommendation_for_user is_personalized_recommendation = is_personalized_recommendation_for_user(course_key) if is_personalized_recommendation is not None: segment_properties['is_personalized_recommendation'] = is_personalized_recommendation + # TODO: VAN-1052 - This is Optimizely's A/B experimentation block to test welcome email redesign. + # Remove this temporary block after pausing the experiment. + optimizely_experiment_variation = None + if optimizely_client and not studio_request: + optimizely_experiment_variation = optimizely_client.activate( + 'welcome_email_redesign_experiment', + str(self.user.id), + { + 'lang_preference': get_user_preference(self.user, LANGUAGE_KEY), + 'is_enterprise_user': is_enterprise_learner(self.user), + } + ) + optimizely_client.track('welcome_email_sent', str(self.user.id)) + if exception_raised and optimizely_experiment_variation == 'redesign_email_enabled': + optimizely_client.track('welcome_email_not_sent', str(self.user.id)) + + # Set this property to True only if the welcome email redesign Optimizely experiment is running + # and user_id falls in required variation. + segment_properties['redesign_email'] = optimizely_experiment_variation == 'redesign_email_enabled' + with tracker.get_tracker().context(event_name, context): tracker.emit(event_name, data) segment.track(self.user_id, event_name, segment_properties, traits=segment_traits) @@ -1621,7 +706,8 @@ def emit_event(self, event_name, enterprise_uuid=None): ) @classmethod - def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=False, enterprise_uuid=None): + def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=False, + enterprise_uuid=None, request=None): """ Enroll a user in a course. This saves immediately. @@ -1716,7 +802,7 @@ def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=Fal # User is allowed to enroll if they've reached this point. enrollment = cls.get_or_create_enrollment(user, course_key) - enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid) + enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid, request=request) enrollment.send_signal(EnrollStatusChange.enroll) # .. event_implemented_name: COURSE_ENROLLMENT_CREATED @@ -2456,595 +1542,126 @@ class ManualEnrollmentAudit(models.Model): """ enrollment = models.ForeignKey(CourseEnrollment, null=True, on_delete=models.CASCADE) enrolled_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE) - 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) - role = models.CharField(blank=True, null=True, max_length=64) - history = HistoricalRecords() - - @classmethod - def create_manual_enrollment_audit(cls, user, email, state_transition, reason, enrollment=None, role=None): - """ - saves the student manual enrollment information - """ - return cls.objects.create( - enrolled_by=user, - enrolled_email=email, - state_transition=state_transition, - reason=reason, - enrollment=enrollment, - role=role, - ) - - @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): - """ - Returns the most recent entry for the given enrollment, or None if there are no matches - """ - try: - manual_enrollment = cls.objects.filter(enrollment=enrollment).latest('time_stamp') - except cls.DoesNotExist: - manual_enrollment = None - return manual_enrollment - - @classmethod - def retire_manual_enrollments(cls, user, retired_email): - """ - Removes PII (enrolled_email and reason) associated with the User passed in. Bubbles up any exceptions. - """ - # This bit of ugliness is to fix a perfmance issue with Django using a slow - # sub-select that caused the original query to take several seconds (PLAT-2371). - # It is possible that this could also be bad if a user has thousands of manual - # enrollments, but currently that number tends to be very low. - manual_enrollment_ids = list(cls.objects.filter(enrollment__user=user).values_list('id', flat=True)) - manual_enrollment_audits = cls.objects.filter(id__in=manual_enrollment_ids) - - if not manual_enrollment_audits: - return False - - for manual_enrollment_audit in manual_enrollment_audits: - manual_enrollment_audit.history.update(reason="", enrolled_email=retired_email) - manual_enrollment_audits.update(reason="", enrolled_email=retired_email) - return True - - -class CourseEnrollmentAllowed(DeletableByUserValue, 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. Once an enrollment from this list effectively happens, - the object is marked with the student who enrolled, to prevent students from changing e-mails and - enrolling many accounts through the same e-mail. - - .. no_pii: - """ - email = models.CharField(max_length=255, db_index=True) - course_id = CourseKeyField(max_length=255, db_index=True) - auto_enroll = models.BooleanField(default=0) - user = models.ForeignKey( - User, - null=True, - blank=True, - help_text="First user which enrolled in the specified course through the specified e-mail. " - "Once set, it won't change.", - on_delete=models.CASCADE, - ) - - created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) - - class Meta: - unique_together = (('email', 'course_id'),) - - def __str__(self): - return f"[CourseEnrollmentAllowed] {self.email}: {self.course_id} ({self.created})" - - @classmethod - def for_user(cls, user): - """ - Returns the CourseEnrollmentAllowed objects that can effectively be used by a particular `user`. - This includes the ones that match the user's e-mail and excludes those CEA which were already consumed - by a different user. - """ - return cls.objects.filter(email=user.email).filter(Q(user__isnull=True) | Q(user=user)) - - def valid_for_user(self, user): - """ - Returns True if the CEA is usable by the given user, or False if it was already consumed by another user. - """ - return self.user is None or self.user == user - - @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. Even if they change their emails after registration. - - `course_id` identifies the course for which to compute the QuerySet. - """ - return CourseEnrollmentAllowed.objects.filter(course_id=course_id, user__isnull=True) - - -@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. - - .. no_pii: - """ - - objects = NoneToEmptyManager() - - user = models.ForeignKey(User, on_delete=models.CASCADE) - # blank org is for global group based roles such as course creator (may be deprecated) - org = models.CharField(max_length=64, db_index=True, blank=True) - # blank course_id implies org wide role - course_id = CourseKeyField(max_length=255, db_index=True, blank=True) - role = models.CharField(max_length=64, db_index=True) - - class Meta: - unique_together = ('user', 'org', 'course_id', 'role') - - @property - def _key(self): - """ - convenience function to make eq overrides easier and clearer. arbitrary decision - that role is primary, followed by org, course, and then user - """ - return (self.role, self.org, self.course_id, self.user_id) - - @classmethod - def access_roles_in_course(cls, course_key): - """ - Returns all CourseAccessRole for a given course and prefetches user information. - """ - return cls.objects.filter( - course_id=course_key, - ).select_related( - 'user', - 'user__profile' - ) - - def __eq__(self, other): - """ - Overriding eq b/c the django impl relies on the primary key which requires fetch. sometimes we - just want to compare roles w/o doing another fetch. - """ - return type(self) == type(other) and self._key == other._key # lint-amnesty, pylint: disable=protected-access, unidiomatic-typecheck - - def __hash__(self): - return hash(self._key) - - def __lt__(self, other): - """ - Lexigraphic sort - """ - return self._key < other._key - - def __str__(self): - return f"[CourseAccessRole] user: {self.user.username} role: {self.role} org: {self.org} course: {self.course_id}" # lint-amnesty, pylint: disable=line-too-long - - -#### Helper methods for use from python manage.py shell and other classes. - - -def strip_if_string(value): - if isinstance(value, str): - return value.strip() - return value - - -def get_user_by_username_or_email(username_or_email): - """ - Return a User object by looking up a user against username_or_email. - - Raises: - User.DoesNotExist if no user object can be found, the user was - retired, or the user is in the process of being retired. - - MultipleObjectsReturned if one user has same email as username of - second user - - MultipleObjectsReturned if more than one user has same email or - username - """ - username_or_email = strip_if_string(username_or_email) - # there should be one user with either username or email equal to username_or_email - user = User.objects.get(Q(email=username_or_email) | Q(username=username_or_email)) - if user.username == username_or_email: - UserRetirementRequest = apps.get_model('user_api', 'UserRetirementRequest') - if UserRetirementRequest.has_user_requested_retirement(user): - raise User.DoesNotExist - return user - - -def get_user(email): - user = User.objects.get(email=email) - u_prof = UserProfile.objects.get(user=user) - return user, u_prof - - -def user_info(email): # lint-amnesty, pylint: disable=missing-function-docstring - 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 - - -def change_email(old_email, new_email): - user = User.objects.get(email=old_email) - user.email = new_email - user.save() - - -def change_name(email, new_name): - _user, u_prof = get_user(email) - u_prof.name = new_name - u_prof.save() - - -def user_count(): - print("All users", User.objects.all().count()) - print("Active users", User.objects.filter(is_active=True).count()) - return User.objects.all().count() - - -def active_user_count(): - return User.objects.filter(is_active=True).count() - - -def create_group(name, description): - utg = UserTestGroup() - utg.name = name - utg.description = description - utg.save() - - -def add_user_to_group(user, group): - utg = UserTestGroup.objects.get(name=group) - utg.users.add(User.objects.get(username=user)) - utg.save() - - -def remove_user_from_group(user, group): - utg = UserTestGroup.objects.get(name=group) - utg.users.remove(User.objects.get(username=user)) - utg.save() - -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' -} - - -def add_user_to_default_group(user, group): # lint-amnesty, pylint: disable=missing-function-docstring - try: - utg = UserTestGroup.objects.get(name=group) - except UserTestGroup.DoesNotExist: - utg = UserTestGroup() - utg.name = group - utg.description = DEFAULT_GROUPS[group] - utg.save() - utg.users.add(User.objects.get(username=user)) - utg.save() - - -def create_comments_service_user(user): # lint-amnesty, pylint: disable=missing-function-docstring - if not settings.FEATURES['ENABLE_DISCUSSION_SERVICE']: - # Don't try--it won't work, and it will fill the logs with lots of errors - return - try: - cc_user = cc.User.from_django_user(user) - cc_user.save() - except Exception: # pylint: disable=broad-except - log = logging.getLogger("edx.discussion") # pylint: disable=redefined-outer-name - log.error( - f"Could not create comments service user with id {user.id}", - exc_info=True - ) - -# Define login and logout handlers here in the models file, instead of the views file, -# so that they are more likely to be loaded when a Studio user brings up the Studio admin -# page to login. These are currently the only signals available, so we need to continue -# identifying and logging failures separately (in views). - - -@receiver(user_logged_in) -def log_successful_login(sender, request, user, **kwargs): # lint-amnesty, pylint: disable=unused-argument - """Handler to log when logins have occurred successfully.""" - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(f"Login success - user.id: {user.id}") - else: - AUDIT_LOG.info(f"Login success - {user.username} ({user.email})") - - -@receiver(user_logged_out) -def log_successful_logout(sender, request, user, **kwargs): # lint-amnesty, pylint: disable=unused-argument - """Handler to log when logouts have occurred successfully.""" - if hasattr(request, 'user'): - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.info(f'Logout - user.id: {request.user.id}') # pylint: disable=logging-format-interpolation - else: - AUDIT_LOG.info(f'Logout - {request.user}') # pylint: disable=logging-format-interpolation - if request.user.id: - segment.track(request.user.id, 'edx.bi.user.account.logout') - - -@receiver(user_logged_in) -@receiver(user_logged_out) -def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: disable=unused-argument - """ - 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 - if user: - user_profile, __ = UserProfile.objects.get_or_create( - user=user, - defaults={'name': user.username} - ) - if user_profile: - user.profile.set_login_session(key) - - -class DashboardConfiguration(ConfigurationModel): - """ - Note: - This model is deprecated and we should not be adding new content to it. - We will eventually migrate this one entry to a django setting as well. - - Dashboard Configuration settings. - - Includes configuration options for the dashboard, which impact behavior and rendering for the application. - - .. no_pii: - """ - 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 - - -class LinkedInAddToProfileConfiguration(ConfigurationModel): - """ - LinkedIn Add to Profile Configuration - - This configuration enables the 'Add to Profile' LinkedIn button. 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. - - See https://addtoprofile.linkedin.com/ for documentation on parameters - - .. no_pii: - """ - - MODE_TO_CERT_NAME = { - 'honor': _('{platform_name} Honor Code Certificate for {course_name}'), - 'verified': _('{platform_name} Verified Certificate for {course_name}'), - 'professional': _('{platform_name} Professional Certificate for {course_name}'), - 'no-id-professional': _('{platform_name} Professional Certificate for {course_name}'), - } - - company_identifier = models.TextField( - blank=True, - help_text=_( - 'Your organization ID (if your organization has an existing page on LinkedIn) e.g 1337. ' - 'If not provided, will default to sending Platform Name (e.g. edX) instead.' - ), - ) + 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) + role = models.CharField(blank=True, null=True, max_length=64) + history = HistoricalRecords() - def is_enabled(self, *key_fields): # pylint: disable=arguments-differ + @classmethod + def create_manual_enrollment_audit(cls, user, email, state_transition, reason, enrollment=None, role=None): """ - Checks both the model itself and share_settings to see if LinkedIn Add to Profile is enabled + saves the student manual enrollment information """ - enabled = super().is_enabled(*key_fields) - share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS) - return share_settings.get('CERTIFICATE_LINKEDIN', enabled) + return cls.objects.create( + enrolled_by=user, + enrolled_email=email, + state_transition=state_transition, + reason=reason, + enrollment=enrollment, + role=role, + ) - def add_to_profile_url(self, course_name, cert_mode, cert_url, certificate=None): + @classmethod + def get_manual_enrollment_by_email(cls, email): """ - Construct the URL for the "add to profile" button. This will autofill the form based on - the params provided. - - Arguments: - course_name (str): The display name of the course. - cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional") - cert_url (str): The URL for the certificate. - - Keyword Arguments: - certificate (GeneratedCertificate): a GeneratedCertificate object for the user and course. - If provided, this function will also autofill the certId and issue date for the cert. + if matches returns the most recent entry in the table filtered by email else returns None. """ - params = { - 'name': self._cert_name(course_name, cert_mode), - 'certUrl': cert_url, - } - - params.update(self._organization_information()) - - if certificate: - params.update({ - 'certId': certificate.verify_uuid, - 'issueYear': certificate.created_date.year, - 'issueMonth': certificate.created_date.month, - }) - - return 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&{params}'.format( - params=urlencode(params) - ) + try: + manual_enrollment = cls.objects.filter(enrolled_email=email).latest('time_stamp') + except cls.DoesNotExist: + manual_enrollment = None + return manual_enrollment - def _cert_name(self, course_name, cert_mode): + @classmethod + def get_manual_enrollment(cls, enrollment): """ - 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. + Returns the most recent entry for the given enrollment, or None if there are no matches """ - default_cert_name = self.MODE_TO_CERT_NAME.get(cert_mode, _('{platform_name} Certificate for {course_name}')) - # 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( - platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), - course_name=course_name - ) + try: + manual_enrollment = cls.objects.filter(enrollment=enrollment).latest('time_stamp') + except cls.DoesNotExist: + manual_enrollment = None + return manual_enrollment - def _organization_information(self): + @classmethod + def retire_manual_enrollments(cls, user, retired_email): """ - Returns organization information for use in the URL parameters for add to profile. - - Returns: - dict: Either the organization ID on LinkedIn or the organization's name - Will be used to prefill the organization on the add to profile action. + Removes PII (enrolled_email and reason) associated with the User passed in. Bubbles up any exceptions. """ - org_id = configuration_helpers.get_value('LINKEDIN_COMPANY_ID', self.company_identifier) - # Prefer organization ID per documentation at https://addtoprofile.linkedin.com/ - if org_id: - return {'organizationId': org_id} - return {'organizationName': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)} + # This bit of ugliness is to fix a perfmance issue with Django using a slow + # sub-select that caused the original query to take several seconds (PLAT-2371). + # It is possible that this could also be bad if a user has thousands of manual + # enrollments, but currently that number tends to be very low. + manual_enrollment_ids = list(cls.objects.filter(enrollment__user=user).values_list('id', flat=True)) + manual_enrollment_audits = cls.objects.filter(id__in=manual_enrollment_ids) + + if not manual_enrollment_audits: + return False + + for manual_enrollment_audit in manual_enrollment_audits: + manual_enrollment_audit.history.update(reason="", enrolled_email=retired_email) + manual_enrollment_audits.update(reason="", enrolled_email=retired_email) + return True -class EntranceExamConfiguration(models.Model): +class CourseEnrollmentAllowed(DeletableByUserValue, models.Model): """ - Represents a Student's entrance exam specific data for a single Course + 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. Once an enrollment from this list effectively happens, + the object is marked with the student who enrolled, to prevent students from changing e-mails and + enrolling many accounts through the same e-mail. .. no_pii: """ - - user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + email = models.CharField(max_length=255, 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) + auto_enroll = models.BooleanField(default=0) + user = models.ForeignKey( + User, + null=True, + blank=True, + help_text="First user which enrolled in the specified course through the specified e-mail. " + "Once set, it won't change.", + on_delete=models.CASCADE, + ) - # if skip_entrance_exam is True, then student can skip entrance exam - # for the course - skip_entrance_exam = models.BooleanField(default=True) + created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) class Meta: - unique_together = (('user', 'course_id'), ) + unique_together = (('email', 'course_id'),) def __str__(self): - return "[EntranceExamConfiguration] {}: {} ({}) = {}".format( - self.user, self.course_id, self.created, self.skip_entrance_exam - ) + return f"[CourseEnrollmentAllowed] {self.email}: {self.course_id} ({self.created})" @classmethod - def user_can_skip_entrance_exam(cls, user, course_key): + def for_user(cls, user): """ - Return True if given user can skip entrance exam for given course otherwise False. + Returns the CourseEnrollmentAllowed objects that can effectively be used by a particular `user`. + This includes the ones that match the user's e-mail and excludes those CEA which were already consumed + by a different user. """ - can_skip = False - if ENTRANCE_EXAMS.is_enabled(): - 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 - - -class LanguageField(models.CharField): - """Represents a language from the ISO 639-1 language set.""" - - def __init__(self, *args, **kwargs): - """Creates a LanguageField. + return cls.objects.filter(email=user.email).filter(Q(user__isnull=True) | Q(user=user)) - 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. + def valid_for_user(self, user): """ - 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().__init__( - max_length=16, - choices=settings.ALL_LANGUAGES, - help_text=help_text, - *args, - **kwargs - ) - - -class LanguageProficiency(models.Model): - """ - Represents a user's language proficiency. - - 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 - /edx-platform/openedx/core/djangoapps/user_api/accounts/views.py or its associated api method - (update_account_settings) so that the events are emitted. - - .. no_pii: Language is not PII value according to OEP-30. - """ - class Meta: - unique_together = (('code', 'user_profile'),) - - user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='language_proficiencies', - on_delete=models.CASCADE) - code = models.CharField( - max_length=16, - blank=False, - choices=settings.ALL_LANGUAGES, - help_text=_("The ISO 639-1 language code for this language.") - ) - - -class SocialLink(models.Model): - """ - Represents a URL connecting a particular social platform to a user's social profile. + Returns True if the CEA is usable by the given user, or False if it was already consumed by another user. + """ + return self.user is None or self.user == user - The platforms are listed in the lms/common.py file under SOCIAL_PLATFORMS. - Each entry has a display name, a url_stub that describes a required - component of the stored URL and an example of a valid URL. + @classmethod + def may_enroll_and_unenrolled(cls, course_id): + """ + Return QuerySet of students who are allowed to enroll in a course. - The stored social_link value must adhere to the form 'https://www.[url_stub][username]'. + Result excludes students who have already enrolled in the + course. Even if they change their emails after registration. - .. pii: Stores linkage from User to a learner's social media profiles. Retired in AccountRetirementView. - .. pii_types: external_service - .. pii_retirement: local_api - """ - user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='social_links', on_delete=models.CASCADE) - platform = models.CharField(max_length=30) - social_link = models.CharField(max_length=100, blank=True) + `course_id` identifies the course for which to compute the QuerySet. + """ + return CourseEnrollmentAllowed.objects.filter(course_id=course_id, user__isnull=True) class CourseEnrollmentAttribute(models.Model): @@ -3154,30 +1771,6 @@ def refund_window(self, refund_window): self.refund_window_microseconds = int(refund_window.total_seconds() * 1000000) -class RegistrationCookieConfiguration(ConfigurationModel): - """ - Configuration for registration cookies. - - .. no_pii: - """ - 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 __str__(self): - """Unicode representation of this config. """ - return "UTM: {utm_name}; AFFILIATE: {affiliate_name}".format( - utm_name=self.utm_cookie_name, - affiliate_name=self.affiliate_cookie_name - ) - - class BulkUnenrollConfiguration(ConfigurationModel): # lint-amnesty, pylint: disable=empty-docstring """ @@ -3202,278 +1795,6 @@ class BulkChangeEnrollmentConfiguration(ConfigurationModel): ) -class UserAttribute(TimeStampedModel): - """ - Record additional metadata about a user, stored as key/value pairs of text. - - .. no_pii: - """ - - class Meta: - # Ensure that at most one value exists for a given user/name. - unique_together = (('user', 'name',), ) - - user = models.ForeignKey(User, related_name='attributes', on_delete=models.CASCADE) - name = models.CharField(max_length=255, help_text=_("Name of this user attribute."), db_index=True) - value = models.CharField(max_length=255, help_text=_("Value of this user attribute.")) - - def __str__(self): - return "[{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.update_or_create(user=user, name=name, defaults={'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 - - -class AccountRecoveryManager(models.Manager): - """ - Custom Manager for AccountRecovery model - """ - - def get_active(self, **filters): - """ - Return only active AccountRecovery record after applying the given filters. - - Arguments: - filters (**kwargs): Filter parameters for AccountRecovery records. - - Returns: - AccountRecovery: AccountRecovery object with is_active=true - """ - filters['is_active'] = True - return super().get_queryset().get(**filters) - - def activate(self): - """ - Set is_active flag to True. - """ - super().get_queryset().update(is_active=True) - - -class AccountRecovery(models.Model): - """ - Model for storing information for user's account recovery in case of access loss. - - .. pii: the field named secondary_email contains pii, retired in the `DeactivateLogoutView` - .. pii_types: email_address - .. pii_retirement: local_api - """ - user = models.OneToOneField(User, related_name='account_recovery', on_delete=models.CASCADE) - secondary_email = models.EmailField( - verbose_name=_('Secondary email address'), - help_text=_('Secondary email address to recover linked account.'), - unique=True, - null=False, - blank=False, - ) - is_active = models.BooleanField(default=False) - - class Meta: - db_table = "auth_accountrecovery" - - objects = AccountRecoveryManager() - - def update_recovery_email(self, email): - """ - Update the secondary email address on the instance to the email in the argument. - - Arguments: - email (str): New email address to be set as the secondary email address. - """ - self.secondary_email = email - self.is_active = True - self.save() - - @classmethod - def retire_recovery_email(cls, user_id): - """ - Retire user's recovery/secondary email as part of GDPR Phase I. - Returns 'True' - - If an AccountRecovery record is found for this user it will be deleted, - if it is not found it is assumed this table has no PII for the given user. - - :param user_id: int - :return: bool - """ - try: - cls.objects.get(user_id=user_id).delete() - except cls.DoesNotExist: - pass - - return True - - -class AllowedAuthUser(TimeStampedModel): - site = models.ForeignKey(Site, related_name='allowed_auth_users', on_delete=models.CASCADE) - email = models.EmailField( - help_text=_( - "An employee (a user whose email has current site's domain name) whose email exists in this model, can be " - "able to login from login screen through email and password. And if any employee's email doesn't exist in " - "this model then that employee can login via third party authentication backend only."), - unique=True, - ) - - -class AccountRecoveryConfiguration(ConfigurationModel): - """ - configuration model for recover account management command - """ - csv_file = models.FileField( - validators=[FileExtensionValidator(allowed_extensions=['csv'])], - help_text=_("It expect that the data will be provided in a csv file format with \ - first row being the header and columns will be as follows: \ - username, current_email, desired_email") - ) - - -class UserCelebration(TimeStampedModel): - """ - Keeps track of how we've celebrated a user's progress on the platform. - This class is for course agnostic celebrations (not specific to a particular enrollment). - CourseEnrollmentCelebration is for celebrations that happen separately for each separate course. - - .. no_pii: - """ - user = models.OneToOneField(User, models.CASCADE, related_name='celebration') - # The last_day_of_streak and streak_length fields are used to - # control celebration of the streak feature. - # A streak is when a learner visits the learning MFE N days in a row. - # The business logic of streaks for a 3 day streak and 1 day break is the following: - # 1. Each streak should be celebrated exactly once, once the learner has completed the streak. - # 2. If a learner misses enough days to count as a break, the streak resets back to 0. - # 3. The streak is measured against the learner's configured timezone - # 4. We keep track of the total length of the streak, so there is a possibility in the future - # to add multiple celebrations for longer streaks. - # 5. We keep track of the longest_ever_streak field for potential future use for badging purposes. - last_day_of_streak = models.DateField(default=None, null=True, blank=True) - streak_length = models.IntegerField(default=0) - longest_ever_streak = models.IntegerField(default=0) - STREAK_LENGTHS_TO_CELEBRATE = [3] - STREAK_BREAK_LENGTH = 1 - - def __str__(self): - return ( - '[UserCelebration] user: {}; last_day_of_streak {}; streak_length {}; longest_ever_streak {};' - ).format(self.user.username, self.last_day_of_streak, self.streak_length, self.longest_ever_streak) - - @classmethod - def _get_now(cls, browser_timezone): - """ Retrieve the value for the current datetime in the user's timezone - - Once a user visits the learning MFE, their streak will not increment until midnight in their timezone. - The decision was to use the user's timezone and not UTC, to make each day of the streak more closely - correspond to separate days for the user. - The learning MFE passes in the browser timezone which is used as a fallback option if the user's timezone - in their account is not set. - UTC is used as a final fallback if neither timezone is set. - """ - # importing here to avoid a circular import - from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs - user_timezone_locale = user_timezone_locale_prefs(crum.get_current_request()) - user_timezone = timezone(user_timezone_locale['user_timezone'] or browser_timezone or str(UTC)) - return user_timezone.localize(datetime.now()) - - def _calculate_streak_updates(self, today): - """ Calculate the updates that should be applied to the streak fields of the provided celebration - A streak is incremented once for each day that a learner accesses the learning MFE. - A break is the amount of time that needs to pass before we stop incrementing the - existing streak and start a brand new streak. - See the UserCelebrationTests class for examples that should help clarify this behavior. - """ - last_day_of_streak = self.last_day_of_streak - streak_length = self.streak_length - streak_length_to_celebrate = None - - first_ever_streak = last_day_of_streak is None - break_length = timedelta(days=self.STREAK_BREAK_LENGTH) - should_start_new_streak = last_day_of_streak and last_day_of_streak + break_length < today - already_updated_streak_today = last_day_of_streak == today - - last_day_of_streak = today - if first_ever_streak or should_start_new_streak: - # Start new streak - streak_length = 1 - elif not already_updated_streak_today: - streak_length += 1 - if streak_length in self.STREAK_LENGTHS_TO_CELEBRATE: - # Celebrate if we didn't already celebrate today - streak_length_to_celebrate = streak_length - - return last_day_of_streak, streak_length, streak_length_to_celebrate - - def _update_streak(self, last_day_of_streak, streak_length): - """ Update the celebration with the new streak data """ - # If anything needs to be updated, update the celebration in the database - if last_day_of_streak != self.last_day_of_streak: - self.last_day_of_streak = last_day_of_streak - self.streak_length = streak_length - self.longest_ever_streak = max(self.longest_ever_streak, streak_length) - - self.save() - - @classmethod - def _get_celebration(cls, user, course_key): - """ Retrieve (or create) the celebration for the provided user and course_key """ - try: - # Only enable the streak if milestones and the streak are enabled for this course - if not streak_celebration_is_active(course_key): - return None - return user.celebration - except (cls.DoesNotExist, User.celebration.RelatedObjectDoesNotExist): # pylint: disable=no-member - celebration, _ = UserCelebration.objects.get_or_create(user=user) - return celebration - - @classmethod - def perform_streak_updates(cls, user, course_key, browser_timezone=None): - """ Determine if the user should see a streak celebration and - return the length of the streak the user should celebrate. - Also update the streak data that is stored in the database.""" - # importing here to avoid a circular import - from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student - if not user or user.is_anonymous: - return None - - if is_masquerading_as_specific_student(user, course_key): - return None - - celebration = cls._get_celebration(user, course_key) - - if not celebration: - return None - - today = cls._get_now(browser_timezone).date() - - # pylint: disable=protected-access - last_day_of_streak, streak_length, streak_length_to_celebrate = \ - celebration._calculate_streak_updates(today) - # pylint: enable=protected-access - - cls._update_streak(celebration, last_day_of_streak, streak_length) - - return streak_length_to_celebrate - - class CourseEnrollmentCelebration(TimeStampedModel): """ Keeps track of how we've celebrated a user's course progress. @@ -3545,19 +1866,3 @@ def should_celebrate_weekly_goal(enrollment): return week_activity_count == goal.days_per_week except CourseGoal.DoesNotExist: return False - - -class UserPasswordToggleHistory(TimeStampedModel): - """ - Keeps track of user password disable/enable history - """ - user = models.ForeignKey(User, related_name='password_toggle_history', on_delete=models.CASCADE) - comment = models.CharField(max_length=255, help_text=_("Add a reason"), blank=True, null=True) - disabled = models.BooleanField(default=True) - created_by = models.ForeignKey(User, on_delete=models.CASCADE) - - class Meta: - ordering = ['-created'] - - def __str__(self): - return self.comment diff --git a/common/djangoapps/student/models/student.py b/common/djangoapps/student/models/student.py new file mode 100644 index 000000000000..b530e4c1878b --- /dev/null +++ b/common/djangoapps/student/models/student.py @@ -0,0 +1,1811 @@ +""" +Models for User Information (students, staff, etc) + +Migration Notes + +If you make changes to this model, be sure to create an appropriate migration +file and check it in at the same time as your model changes. To do that, + +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/ +""" + +import crum +import hashlib # lint-amnesty, pylint: disable=wrong-import-order +import json # lint-amnesty, pylint: disable=wrong-import-order +import logging # lint-amnesty, pylint: disable=wrong-import-order +import openedx.core.djangoapps.django_comment_common.comment_client as cc +import uuid # lint-amnesty, pylint: disable=wrong-import-order +from common.djangoapps.student.models.course_enrollment import ( + ALLOWEDTOENROLL_TO_ENROLLED, + CourseEnrollment, + CourseEnrollmentAllowed, + ManualEnrollmentAudit, + segment +) +from common.djangoapps.util.model_utils import emit_field_changed_events, get_changed_fields_dict +from config_models.models import ConfigurationModel +from datetime import datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order +from django.apps import apps +from django.conf import settings +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist +from django.core.validators import FileExtensionValidator, RegexValidator +from django.db import IntegrityError, models +from django.db.models import Q +from django.db.models.signals import post_save, pre_save +from django.db.utils import ProgrammingError +from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_noop +from django_countries.fields import CountryField +from edx_django_utils import monitoring +from edx_django_utils.cache import RequestCache +from functools import total_ordering # lint-amnesty, pylint: disable=wrong-import-order +from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order +from lms.djangoapps.courseware.toggles import streak_celebration_is_active +from model_utils.models import TimeStampedModel +from opaque_keys.edx.django.models import CourseKeyField, LearningContextKeyField +from openedx.core.djangoapps.signals.signals import USER_ACCOUNT_ACTIVATED +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager +from openedx.core.djangolib.model_mixins import DeletableByUserValue +from openedx.core.toggles import ENTRANCE_EXAMS +from pytz import UTC, timezone +from urllib.parse import unquote, urlencode +from user_util import user_util + +log = logging.getLogger(__name__) +AUDIT_LOG = logging.getLogger("audit") +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # pylint: disable=invalid-name + +IS_MARKETABLE = 'is_marketable' + + +class AnonymousUserId(models.Model): + """ + This table contains user, course_Id and anonymous_user_id + + Purpose of this table is to provide user by anonymous_user_id. + + We generate anonymous_user_id using md5 algorithm, + and use result in hex form, so its length is equal to 32 bytes. + + .. no_pii: We store anonymous_user_ids here, but do not consider them PII under OEP-30. + """ + + objects = NoneToEmptyManager() + + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + anonymous_user_id = models.CharField(unique=True, max_length=32) + course_id = LearningContextKeyField(db_index=True, max_length=255, blank=True) + + +def anonymous_id_for_user(user, course_id): + """ + Inputs: + user: User model + course_id: string or None + + Return a unique id for a (user, course_id) pair, suitable for inserting + into e.g. personalized survey links. + + If user is an `AnonymousUser`, returns `None` + else If this user/course_id pair already has an anonymous id in AnonymousUserId object, return that + else: create new anonymous_id, save it in AnonymousUserId, and return anonymous id + """ + + # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated. + assert user + + if user.is_anonymous: + return None + + # ARCHBOM-1674: Get a sense of what fraction of anonymous_user_id calls are + # cached, stored in the DB, or retrieved from the DB. This will help inform + # us on decisions about whether we can + # pregenerate IDs, use random instead of deterministic IDs, etc. + monitoring.increment('temp_anon_uid_v2.requested') + + cached_id = getattr(user, '_anonymous_id', {}).get(course_id) + if cached_id is not None: + monitoring.increment('temp_anon_uid_v2.returned_from_cache') + return cached_id + # Check if an anonymous id already exists for this user and + # course_id combination. Prefer the one with the highest record ID + # (see below.) + anonymous_user_ids = AnonymousUserId.objects.filter(user=user).filter(course_id=course_id).order_by('-id') + if anonymous_user_ids: + # If there are multiple anonymous_user_ids per user, course_id pair + # select the row which was created most recently. + # There might be more than one if the Django SECRET_KEY had + # previously been rotated at a time before this function was + # changed to always save the generated IDs to the DB. In that + # case, just pick the one with the highest record ID, which is + # probably the most recently created one. + anonymous_user_id = anonymous_user_ids[0].anonymous_user_id + monitoring.increment('temp_anon_uid_v2.fetched_existing') + else: + # Uses SECRET_KEY as a cryptographic pepper. This + # deterministic ID generation means that concurrent identical + # calls to this function return the same value -- no need for + # locking. (There may be a low level of integrity errors on + # creation as a result of concurrent duplicate row inserts.) + # + # Consequences for this function of SECRET_KEY exposure: Data + # researchers and other third parties receiving these + # anonymous user IDs would be able to identify users across + # courses, and predict the anonymous user IDs of all users + # (but not necessarily identify their accounts.) + # + # Rotation process of SECRET_KEY with respect to this + # function: Rotate at will, since the hashes are stored and + # will not change. + # include the secret key as a salt, and to make the ids unique across different LMS installs. + hasher = hashlib.shake_128() + hasher.update(settings.SECRET_KEY.encode('utf8')) + hasher.update(str(user.id).encode('utf8')) + if course_id: + hasher.update(str(course_id).encode('utf-8')) + anonymous_user_id = hasher.hexdigest(16) # pylint: disable=too-many-function-args + + try: + AnonymousUserId.objects.create( + user=user, + course_id=course_id, + anonymous_user_id=anonymous_user_id, + ) + monitoring.increment('temp_anon_uid_v2.stored') + except IntegrityError: + # Another thread has already created this entry, so + # continue + monitoring.increment('temp_anon_uid_v2.store_db_error') + + # cache the anonymous_id in the user object + if not hasattr(user, '_anonymous_id'): + user._anonymous_id = {} # pylint: disable=protected-access + user._anonymous_id[course_id] = anonymous_user_id # pylint: disable=protected-access + + return anonymous_user_id + + +def user_by_anonymous_id(uid): + """ + Return user by anonymous_user_id using AnonymousUserId lookup table. + + Do not raise `django.ObjectDoesNotExist` exception, + if there is no user for anonymous_student_id, + because this function will be used inside xmodule w/o django access. + """ + + if uid is None: + return None + + request_cache = RequestCache('user_by_anonymous_id') + cache_response = request_cache.get_cached_response(uid) + if cache_response.is_found: + return cache_response.value + + try: + user = User.objects.get(anonymoususerid__anonymous_user_id=uid) + request_cache.set(uid, user) + return user + except ObjectDoesNotExist: + request_cache.set(uid, None) + return None + + +def is_username_retired(username): + """ + Checks to see if the given username has been previously retired + """ + locally_hashed_usernames = user_util.get_all_retired_usernames( + username, + settings.RETIRED_USER_SALTS, + settings.RETIRED_USERNAME_FMT + ) + + # TODO: Revert to this after username capitalization issues detailed in + # PLAT-2276, PLAT-2277, PLAT-2278 are sorted out: + # return User.objects.filter(username__in=list(locally_hashed_usernames)).exists() + + # Avoid circular import issues + from openedx.core.djangoapps.user_api.models import UserRetirementStatus + + # Sandbox clean builds attempt to create users during migrations, before the database + # is stable so UserRetirementStatus may not exist yet. This workaround can also go + # when we are done with the username updates. + try: + return User.objects.filter(username__in=list(locally_hashed_usernames)).exists() or \ + UserRetirementStatus.objects.filter(original_username=username).exists() + except ProgrammingError as exc: + # Check the error message to make sure it's what we expect + if "user_api_userretirementstatus" in str(exc): + return User.objects.filter(username__in=list(locally_hashed_usernames)).exists() + raise + + +def username_exists_or_retired(username): + """ + Check a username for existence -or- retirement against the User model. + """ + return User.objects.filter(username=username).exists() or is_username_retired(username) + + +def is_email_retired(email): + """ + Checks to see if the given email has been previously retired + """ + locally_hashed_emails = user_util.get_all_retired_emails( + email, + settings.RETIRED_USER_SALTS, + settings.RETIRED_EMAIL_FMT + ) + + return User.objects.filter(email__in=list(locally_hashed_emails)).exists() + + +def email_exists_or_retired(email): + """ + Check an email against the User model for existence. + """ + return ( + User.objects.filter(email=email).exists() or + is_email_retired(email) or + AccountRecovery.objects.filter(secondary_email=email).exists() + ) + + +def get_retired_username_by_username(username): + """ + If a UserRetirementStatus object with an original_username matching the given username exists, + returns that UserRetirementStatus.retired_username value. Otherwise, returns a "retired username" + hashed using the newest configured salt. + """ + UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus') + try: + status = UserRetirementStatus.objects.filter(original_username=username).order_by('-modified').first() + if status: + return status.retired_username + except UserRetirementStatus.DoesNotExist: + pass + return user_util.get_retired_username(username, settings.RETIRED_USER_SALTS, settings.RETIRED_USERNAME_FMT) + + +def get_retired_email_by_email(email): + """ + If a UserRetirementStatus object with an original_email matching the given email exists, + returns that UserRetirementStatus.retired_email value. Otherwise, returns a "retired email" + hashed using the newest configured salt. + """ + UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus') + try: + status = UserRetirementStatus.objects.filter(original_email=email).order_by('-modified').first() + if status: + return status.retired_email + except UserRetirementStatus.DoesNotExist: + pass + return user_util.get_retired_email(email, settings.RETIRED_USER_SALTS, settings.RETIRED_EMAIL_FMT) + + +def get_all_retired_usernames_by_username(username): + """ + Returns a generator of "retired usernames", one hashed with each + configured salt. Used for finding out if the given username has + ever been used and retired. + """ + return user_util.get_all_retired_usernames(username, settings.RETIRED_USER_SALTS, settings.RETIRED_USERNAME_FMT) + + +def get_potentially_retired_user_by_username(username): + """ + Attempt to return a User object based on the username, or if it + does not exist, then any hashed username salted with the historical + salts. + """ + locally_hashed_usernames = list(get_all_retired_usernames_by_username(username)) + locally_hashed_usernames.append(username) + potential_users = User.objects.filter(username__in=locally_hashed_usernames) + + # Have to disambiguate between several Users here as we could have retirees with + # the same username, but for case. + # If there's only 1 we're done, this should be the common case + if len(potential_users) == 1: + return potential_users[0] + + # No user found, throw the usual error + if not potential_users: + raise User.DoesNotExist() + + # For a brief period, users were able to retire accounts and make another account with + # the same differently-cased username, like "testuser" and "TestUser". + # If there are two users found, return the one that's the *actual* case-matching username, + # whether retired or not. + if len(potential_users) == 2: + # Figure out which user has been retired. + if potential_users[0].username.startswith(settings.RETIRED_USERNAME_PREFIX): + retired = potential_users[0] + active = potential_users[1] + else: + retired = potential_users[1] + active = potential_users[0] + + # If the active (non-retired) user's username doesn't *exactly* match (including case), + # then the retired account must be the one that exactly matches. + return active if active.username == username else retired + + # We should have, at most, a retired username and an active one with a username + # differing only by case. If there are more we need to disambiguate them by hand. + raise Exception(f'Expected 1 or 2 Users, received {str(potential_users)}') + + +def get_potentially_retired_user_by_username_and_hash(username, hashed_username): + """ + To assist in the retirement process this method will: + - Confirm that any locally hashed username matches the passed in one + (in case of salt mismatches with the upstream script). + - Attempt to return a User object based on the username, or if it + does not exist, the any hashed username salted with the historical + salts. + """ + locally_hashed_usernames = list(get_all_retired_usernames_by_username(username)) + + if hashed_username not in locally_hashed_usernames: + raise Exception('Mismatched hashed_username, bad salt?') + + locally_hashed_usernames.append(username) + return User.objects.get(username__in=locally_hashed_usernames) + + +def is_personalized_recommendation_for_user(course_id): + """ + Returns the personalized recommendation value from the cookie. + """ + request = crum.get_current_request() + recommended_courses = \ + request.COOKIES.get(settings.PERSONALIZED_RECOMMENDATION_COOKIE_NAME, None) if request else None + + if recommended_courses: + recommended_courses = json.loads(unquote(recommended_courses)) + if course_id in recommended_courses['course_keys']: + return recommended_courses['is_personalized_recommendation'] + return None + + +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. + + .. no_pii: + """ + ACCOUNT_DISABLED = "disabled" + ACCOUNT_ENABLED = "enabled" + USER_STANDING_CHOICES = ( + (ACCOUNT_DISABLED, "Account Disabled"), + (ACCOUNT_ENABLED, "Account Enabled"), + ) + + user = models.OneToOneField(User, db_index=True, related_name='standing', on_delete=models.CASCADE) + account_status = models.CharField( + blank=True, max_length=31, choices=USER_STANDING_CHOICES + ) + changed_by = models.ForeignKey(User, blank=True, on_delete=models.CASCADE) + standing_last_changed_at = models.DateTimeField(auto_now=True) + + +class UserProfile(models.Model): + """This is where we store all the user demographic fields. We have a + separate table for this rather than extending the built-in Django auth_user. + + Notes: + * Some fields are legacy ones from the first run of 6.002, from which + we imported many users. + * Fields like name and address are intentionally open ended, to account + for international variations. An unfortunate side-effect is that we + cannot efficiently sort on last names for instance. + + Replication: + * Only the Portal servers should ever modify this information. + * All fields are replicated into relevant Course databases + + Some of the fields are legacy ones that were captured during the initial + MITx fall prototype. + + .. pii: Contains many PII fields. Retired in AccountRetirementView. + .. pii_types: name, location, birth_date, gender, biography, phone_number + .. pii_retirement: local_api + """ + # cache key format e.g user..profile.country = 'SG' + PROFILE_COUNTRY_CACHE_KEY = "user.{user_id}.profile.country" + + class Meta: + db_table = "auth_userprofile" + permissions = (("can_deactivate_users", "Can deactivate, but NOT delete users"),) + + # CRITICAL TODO/SECURITY + # Sanitize all fields. + # This is not visible to other users, but could introduce holes later + user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile', on_delete=models.CASCADE) + name = models.CharField(blank=True, max_length=255, db_index=True) + + # How meta field works: meta will only store those fields which are available in extended_profile configuration, + # so in order to store a field in meta, it must be available in extended_profile configuration. + meta = models.TextField(blank=True) # JSON dictionary for future expansion + courseware = models.CharField(blank=True, max_length=255, default='course.xml') + + # Language is deprecated and no longer used. Old rows exist that have + # user-entered free form text values (ex. "English"), some of which have + # non-ASCII values. You probably want UserPreference version of this, which + # stores the user's preferred language code. See openedx/core/djangoapps/lang_pref + # for more information. + language = models.CharField(blank=True, max_length=255, db_index=True) + + # Location is no longer used, but is held here for backwards compatibility + # for users imported from our first class. + location = models.CharField(blank=True, max_length=255, db_index=True) + + # Optional demographic data we started capturing from Fall 2012 + this_year = datetime.now(UTC).year + VALID_YEARS = list(range(this_year, this_year - 120, -1)) + year_of_birth = models.IntegerField(blank=True, null=True, db_index=True) + GENDER_CHOICES = ( + ('m', gettext_noop('Male')), + ('f', gettext_noop('Female')), + # Translators: 'Other' refers to the student's gender + ('o', gettext_noop('Other/Prefer Not to Say')) + ) + gender = models.CharField( + blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES + ) + + # [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'), + LEVEL_OF_EDUCATION_CHOICES = ( + ('p', gettext_noop('Doctorate')), + ('m', gettext_noop("Master's or professional degree")), + ('b', gettext_noop("Bachelor's degree")), + ('a', gettext_noop("Associate degree")), + ('hs', gettext_noop("Secondary/high school")), + ('jhs', gettext_noop("Junior secondary/junior high/middle school")), + ('el', gettext_noop("Elementary/primary school")), + # Translators: 'None' refers to the student's level of education + ('none', gettext_noop("No formal education")), + # Translators: 'Other' refers to the student's level of education + ('other', gettext_noop("Other education")) + ) + level_of_education = models.CharField( + blank=True, null=True, max_length=6, db_index=True, + choices=LEVEL_OF_EDUCATION_CHOICES + ) + mailing_address = models.TextField(blank=True, null=True) + city = models.TextField(blank=True, null=True) + country = CountryField(blank=True, null=True) + COUNTRY_WITH_STATES = 'US' + STATE_CHOICES = ( + ('AL', 'Alabama'), + ('AK', 'Alaska'), + ('AZ', 'Arizona'), + ('AR', 'Arkansas'), + ('AA', 'Armed Forces Americas'), + ('AE', 'Armed Forces Europe'), + ('AP', 'Armed Forces Pacific'), + ('CA', 'California'), + ('CO', 'Colorado'), + ('CT', 'Connecticut'), + ('DE', 'Delaware'), + ('DC', 'District Of Columbia'), + ('FL', 'Florida'), + ('GA', 'Georgia'), + ('HI', 'Hawaii'), + ('ID', 'Idaho'), + ('IL', 'Illinois'), + ('IN', 'Indiana'), + ('IA', 'Iowa'), + ('KS', 'Kansas'), + ('KY', 'Kentucky'), + ('LA', 'Louisiana'), + ('ME', 'Maine'), + ('MD', 'Maryland'), + ('MA', 'Massachusetts'), + ('MI', 'Michigan'), + ('MN', 'Minnesota'), + ('MS', 'Mississippi'), + ('MO', 'Missouri'), + ('MT', 'Montana'), + ('NE', 'Nebraska'), + ('NV', 'Nevada'), + ('NH', 'New Hampshire'), + ('NJ', 'New Jersey'), + ('NM', 'New Mexico'), + ('NY', 'New York'), + ('NC', 'North Carolina'), + ('ND', 'North Dakota'), + ('OH', 'Ohio'), + ('OK', 'Oklahoma'), + ('OR', 'Oregon'), + ('PA', 'Pennsylvania'), + ('RI', 'Rhode Island'), + ('SC', 'South Carolina'), + ('SD', 'South Dakota'), + ('TN', 'Tennessee'), + ('TX', 'Texas'), + ('UT', 'Utah'), + ('VT', 'Vermont'), + ('VA', 'Virginia'), + ('WA', 'Washington'), + ('WV', 'West Virginia'), + ('WI', 'Wisconsin'), + ('WY', 'Wyoming'), + ) + state = models.CharField(blank=True, null=True, max_length=2, choices=STATE_CHOICES) + goals = models.TextField(blank=True, null=True) + bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False) + profile_image_uploaded_at = models.DateTimeField(null=True, blank=True) + phone_regex = RegexValidator(regex=r'^\+?1?\d*$', message="Phone number can only contain numbers.") + phone_number = models.CharField(validators=[phone_regex], blank=True, null=True, max_length=50) + + @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 + + @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: + return self._calculate_age(year, year_of_birth) + + @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) + + def get_meta(self): # pylint: disable=missing-function-docstring + js_str = self.meta + if not js_str: + js_str = {} + else: + js_str = json.loads(self.meta) + + return js_str + + def set_meta(self, meta_json): + self.meta = json.dumps(meta_json) + + 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() + + def requires_parental_consent(self, year=None, age_limit=None, default_requires_consent=True): + """Returns true if this user requires parental consent. + + Args: + year (int): The year 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 + + if year is None: + age = self.age + else: + age = self._calculate_age(year, year_of_birth) + + return age < age_limit + + 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] + + 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 + + @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 + """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) + + +@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: + user_profile.profile_image_uploaded_at = None + + # 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) # lint-amnesty, pylint: disable=protected-access + + +@receiver(post_save, sender=UserProfile) +def user_profile_post_save_callback(sender, **kwargs): + """ + Emit analytics events after saving the UserProfile. + """ + user_profile = kwargs['instance'] + emit_field_changed_events( + user_profile, + user_profile.user, + sender._meta.db_table, + excluded_fields=['meta'] + ) + + +@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) # lint-amnesty, pylint: disable=protected-access + + +@receiver(post_save, sender=User) +def user_post_save_callback(sender, **kwargs): + """ + When a user is modified and either its `is_active` state or email address + is changed, and the user is, in fact, active, then check to see if there + are any courses that it needs to be automatically enrolled in and enroll them if needed. + + Additionally, emit analytics events after saving the User. + """ + user = kwargs['instance'] + + changed_fields = user._changed_fields # lint-amnesty, pylint: disable=protected-access + + if 'is_active' in changed_fields or 'email' in changed_fields: + if user.is_active: + ceas = CourseEnrollmentAllowed.for_user(user).filter(auto_enroll=True) + + for cea in ceas: + # skip enrolling already enrolled users + if CourseEnrollment.is_enrolled(user, cea.course_id): + # Link the CEA to the user if the CEA isn't already linked to the user + # (e.g. the user was invited to a course but hadn't activated the account yet) + # This is to prevent students from changing e-mails and + # enrolling many accounts through the same e-mail. + if not cea.user: + cea.user = user + cea.save() + continue + + enrollment = CourseEnrollment.enroll(user, cea.course_id) + + manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(user.email) + if manual_enrollment_audit is not None: + # get the enrolled by user and reason from the ManualEnrollmentAudit table. + # then create a new ManualEnrollmentAudit table entry for the same email + # different transition state. + ManualEnrollmentAudit.create_manual_enrollment_audit( + manual_enrollment_audit.enrolled_by, + user.email, + ALLOWEDTOENROLL_TO_ENROLLED, + manual_enrollment_audit.reason, + enrollment + ) + + # Ensure the user has a profile when run via management command + _called_by_management_command = getattr(user, '_called_by_management_command', None) + if _called_by_management_command: + try: + profile = user.profile + except UserProfile.DoesNotExist: + profile = UserProfile.objects.create(user=user) + log.info('Created new profile for user: %s', user) + + # If user is created using management command, ensure that the user's + # marketable attribute is set (default: False) and an account is created + # on segment. By created an account on segment, it is ensured that data + # will be sent to relevant places like Braze. + if settings.MARKETING_EMAILS_OPT_IN: + UserAttribute.set_user_attribute(user, IS_MARKETABLE, 'false') + + traits = { + 'email': user.email, + 'username': user.username, + 'name': profile.name, + 'age': profile.age or -1, + 'yearOfBirth': profile.year_of_birth or datetime.now(UTC).year, + 'education': profile.level_of_education_display, + 'address': profile.mailing_address, + 'gender': profile.gender_display, + 'country': str(profile.country), + 'is_marketable': False + } + # .. pii: Many pieces of PII are sent to Segment here. Retired directly through Segment API call in Tubular. + # .. pii_types: email_address, username + # .. pii_retirement: third_party + segment.identify(user.id, traits) + + # Because `emit_field_changed_events` removes the record of the fields that + # were changed, wait to do that until after we've checked them as part of + # the condition on whether we want to check for automatic enrollments. + emit_field_changed_events( + user, + user, + sender._meta.db_table, + excluded_fields=['last_login', 'first_name', 'last_name'], + hidden_fields=['password'] + ) + + +class UserSignupSource(models.Model): + """ + This table contains information about users registering + via Micro-Sites + + .. no_pii: + """ + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + site = models.CharField(max_length=255, db_index=True) + + +def unique_id_for_user(user): + """ + Return a unique id for a user, suitable for inserting into + e.g. personalized survey links. + """ + # Setting course_id to '' makes it not affect the generated hash, + # and thus produce the old per-student anonymous id + return anonymous_id_for_user(user, None) + + +# TODO: Should be renamed to generic UserGroup, and possibly +# Given an optional field for type of group +class UserTestGroup(models.Model): + """ + .. no_pii: + """ + users = models.ManyToManyField(User, db_index=True) + name = models.CharField(blank=False, max_length=32, db_index=True) + description = models.TextField(blank=True) + + +class Registration(models.Model): + """ + Allows us to wait for e-mail before user is registered. A + registration profile is created when the user creates an + account, but that account is inactive. Once the user clicks + on the activation key, it becomes active. + + .. no_pii: + """ + + class Meta: + db_table = "auth_registration" + + user = models.OneToOneField(User, on_delete=models.CASCADE) + activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) + activation_timestamp = models.DateTimeField(default=None, null=True, blank=True) + + def register(self, user): + # MINOR TODO: Switch to crypto-secure key + self.activation_key = uuid.uuid4().hex + self.user = user + self.save() + + def activate(self): # lint-amnesty, pylint: disable=missing-function-docstring + self.user.is_active = True + self.user.save(update_fields=['is_active']) + self.activation_timestamp = datetime.utcnow() + self.save() + USER_ACCOUNT_ACTIVATED.send_robust(self.__class__, user=self.user) + log.info('User %s (%s) account is successfully activated.', self.user.username, self.user.email) + + +class PendingNameChange(DeletableByUserValue, models.Model): + """ + This model keeps track of pending requested changes to a user's name. + + .. pii: Contains new_name, retired in LMSAccountRetirementView + .. pii_types: name + .. pii_retirement: local_api + """ + user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE) + new_name = models.CharField(blank=True, max_length=255) + rationale = models.CharField(blank=True, max_length=1024) + + +class PendingEmailChange(DeletableByUserValue, models.Model): + """ + This model keeps track of pending requested changes to a user's email address. + + .. pii: Contains new_email, retired in AccountRetirementView + .. pii_types: email_address + .. pii_retirement: local_api + """ + user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE) + new_email = models.CharField(blank=True, max_length=255, db_index=True) + activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) + + 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 + + +class PendingSecondaryEmailChange(DeletableByUserValue, models.Model): + """ + This model keeps track of pending requested changes to a user's secondary email address. + + .. pii: Contains new_secondary_email, not currently retired + .. pii_types: email_address + .. pii_retirement: retained + """ + user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE) + new_secondary_email = models.CharField(blank=True, max_length=255, db_index=True) + activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) + + +class LoginFailures(models.Model): + """ + This model will keep track of failed login attempts. + + .. no_pii: + """ + user = models.ForeignKey(User, on_delete=models.CASCADE) + failure_count = models.IntegerField(default=0) + lockout_until = models.DateTimeField(null=True) + + @classmethod + 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 + def is_feature_enabled(cls): + """ + Returns whether the feature flag around this functionality has been set + """ + return settings.FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] + + @classmethod + def is_user_locked_out(cls, user): + """ + Static method to return in a given user has his/her account locked out + """ + try: + record = cls._get_record_for_user(user) + if not record.lockout_until: + return False + + now = datetime.now(UTC) + until = record.lockout_until + is_locked_out = until and now < until + + return is_locked_out + except ObjectDoesNotExist: + return False + + @classmethod + def increment_lockout_counter(cls, user): + """ + Ticks the failed attempt counter + """ + record, _ = LoginFailures.objects.get_or_create(user=user) + record.failure_count = record.failure_count + 1 + max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED + + # did we go over the limit in attempts + if record.failure_count >= max_failures_allowed: + # yes, then store when this account is locked out until + lockout_period_secs = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS + record.lockout_until = datetime.now(UTC) + timedelta(seconds=lockout_period_secs) + + record.save() + + @classmethod + def check_user_reset_password_threshold(cls, user): + """ + Checks if the user is above threshold for reset password message. + """ + record, _ = LoginFailures.objects.get_or_create(user=user) + max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED + + return record.failure_count >= max_failures_allowed / 2, record.failure_count + + @classmethod + def clear_lockout_counter(cls, user): + """ + Removes the lockout counters (normally called after a successful login) + """ + try: + entry = cls._get_record_for_user(user) + entry.delete() + except ObjectDoesNotExist: + return + + def __str__(self): + """Str -> Username: count - date.""" + return '{username}: {count} - {date}'.format( + username=self.user.username, + count=self.failure_count, + date=self.lockout_until.isoformat() if self.lockout_until else '-' + ) + + class Meta: + verbose_name = 'Login Failure' + verbose_name_plural = 'Login Failures' + +@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. + + .. no_pii: + """ + + objects = NoneToEmptyManager() + + user = models.ForeignKey(User, on_delete=models.CASCADE) + # blank org is for global group based roles such as course creator (may be deprecated) + org = models.CharField(max_length=64, db_index=True, blank=True) + # blank course_id implies org wide role + course_id = CourseKeyField(max_length=255, db_index=True, blank=True) + role = models.CharField(max_length=64, db_index=True) + + class Meta: + unique_together = ('user', 'org', 'course_id', 'role') + + @property + def _key(self): + """ + convenience function to make eq overrides easier and clearer. arbitrary decision + that role is primary, followed by org, course, and then user + """ + return (self.role, self.org, self.course_id, self.user_id) + + @classmethod + def access_roles_in_course(cls, course_key): + """ + Returns all CourseAccessRole for a given course and prefetches user information. + """ + return cls.objects.filter( + course_id=course_key, + ).select_related( + 'user', + 'user__profile' + ) + + def __eq__(self, other): + """ + Overriding eq b/c the django impl relies on the primary key which requires fetch. sometimes we + just want to compare roles w/o doing another fetch. + """ + return type(self) == type(other) and self._key == other._key # lint-amnesty, pylint: disable=protected-access, unidiomatic-typecheck + + def __hash__(self): + return hash(self._key) + + def __lt__(self, other): + """ + Lexigraphic sort + """ + return self._key < other._key + + def __str__(self): + return f"[CourseAccessRole] user: {self.user.username} role: {self.role} org: {self.org} course: {self.course_id}" # lint-amnesty, pylint: disable=line-too-long + + +#### Helper methods for use from python manage.py shell and other classes. + + +def strip_if_string(value): + if isinstance(value, str): + return value.strip() + return value + + +def get_user_by_username_or_email(username_or_email): + """ + Return a User object by looking up a user against username_or_email. + + Raises: + User.DoesNotExist if no user object can be found, the user was + retired, or the user is in the process of being retired. + + MultipleObjectsReturned if one user has same email as username of + second user + + MultipleObjectsReturned if more than one user has same email or + username + """ + username_or_email = strip_if_string(username_or_email) + # there should be one user with either username or email equal to username_or_email + user = User.objects.get(Q(email=username_or_email) | Q(username=username_or_email)) + if user.username == username_or_email: + UserRetirementRequest = apps.get_model('user_api', 'UserRetirementRequest') + if UserRetirementRequest.has_user_requested_retirement(user): + raise User.DoesNotExist + return user + + +def get_user(email): + user = User.objects.get(email=email) + u_prof = UserProfile.objects.get(user=user) + return user, u_prof + + +def user_info(email): # lint-amnesty, pylint: disable=missing-function-docstring + 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 + + +def change_email(old_email, new_email): + user = User.objects.get(email=old_email) + user.email = new_email + user.save() + + +def change_name(email, new_name): + _user, u_prof = get_user(email) + u_prof.name = new_name + u_prof.save() + + +def user_count(): + print("All users", User.objects.all().count()) + print("Active users", User.objects.filter(is_active=True).count()) + return User.objects.all().count() + + +def active_user_count(): + return User.objects.filter(is_active=True).count() + + +def create_group(name, description): + utg = UserTestGroup() + utg.name = name + utg.description = description + utg.save() + + +def add_user_to_group(user, group): + utg = UserTestGroup.objects.get(name=group) + utg.users.add(User.objects.get(username=user)) + utg.save() + + +def remove_user_from_group(user, group): + utg = UserTestGroup.objects.get(name=group) + utg.users.remove(User.objects.get(username=user)) + utg.save() + +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' +} + + +def add_user_to_default_group(user, group): # lint-amnesty, pylint: disable=missing-function-docstring + try: + utg = UserTestGroup.objects.get(name=group) + except UserTestGroup.DoesNotExist: + utg = UserTestGroup() + utg.name = group + utg.description = DEFAULT_GROUPS[group] + utg.save() + utg.users.add(User.objects.get(username=user)) + utg.save() + + +def create_comments_service_user(user): # lint-amnesty, pylint: disable=missing-function-docstring + if not settings.FEATURES['ENABLE_DISCUSSION_SERVICE']: + # Don't try--it won't work, and it will fill the logs with lots of errors + return + try: + cc_user = cc.User.from_django_user(user) + cc_user.save() + except Exception: # pylint: disable=broad-except + log = logging.getLogger("edx.discussion") # pylint: disable=redefined-outer-name + log.error( + f"Could not create comments service user with id {user.id}", + exc_info=True + ) + +# Define login and logout handlers here in the models file, instead of the views file, +# so that they are more likely to be loaded when a Studio user brings up the Studio admin +# page to login. These are currently the only signals available, so we need to continue +# identifying and logging failures separately (in views). + + +@receiver(user_logged_in) +def log_successful_login(sender, request, user, **kwargs): # lint-amnesty, pylint: disable=unused-argument + """Handler to log when logins have occurred successfully.""" + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(f"Login success - user.id: {user.id}") + else: + AUDIT_LOG.info(f"Login success - {user.username} ({user.email})") + + +@receiver(user_logged_out) +def log_successful_logout(sender, request, user, **kwargs): # lint-amnesty, pylint: disable=unused-argument + """Handler to log when logouts have occurred successfully.""" + if hasattr(request, 'user'): + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.info(f'Logout - user.id: {request.user.id}') # pylint: disable=logging-format-interpolation + else: + AUDIT_LOG.info(f'Logout - {request.user}') # pylint: disable=logging-format-interpolation + if request.user.id: + segment.track(request.user.id, 'edx.bi.user.account.logout') + + +@receiver(user_logged_in) +@receiver(user_logged_out) +def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: disable=unused-argument + """ + 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 + if user: + user_profile, __ = UserProfile.objects.get_or_create( + user=user, + defaults={'name': user.username} + ) + if user_profile: + user.profile.set_login_session(key) + + +class DashboardConfiguration(ConfigurationModel): + """ + Note: + This model is deprecated and we should not be adding new content to it. + We will eventually migrate this one entry to a django setting as well. + + Dashboard Configuration settings. + + Includes configuration options for the dashboard, which impact behavior and rendering for the application. + + .. no_pii: + """ + 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 + + +class LinkedInAddToProfileConfiguration(ConfigurationModel): + """ + LinkedIn Add to Profile Configuration + + This configuration enables the 'Add to Profile' LinkedIn button. 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. + + See https://addtoprofile.linkedin.com/ for documentation on parameters + + .. no_pii: + """ + + MODE_TO_CERT_NAME = { + 'honor': _('{platform_name} Honor Code Certificate for {course_name}'), + 'verified': _('{platform_name} Verified Certificate for {course_name}'), + 'professional': _('{platform_name} Professional Certificate for {course_name}'), + 'no-id-professional': _('{platform_name} Professional Certificate for {course_name}'), + } + + company_identifier = models.TextField( + blank=True, + help_text=_( + 'Your organization ID (if your organization has an existing page on LinkedIn) e.g 1337. ' + 'If not provided, will default to sending Platform Name (e.g. edX) instead.' + ), + ) + + def is_enabled(self, *key_fields): # pylint: disable=arguments-differ + """ + Checks both the model itself and share_settings to see if LinkedIn Add to Profile is enabled + """ + enabled = super().is_enabled(*key_fields) + share_settings = configuration_helpers.get_value('SOCIAL_SHARING_SETTINGS', settings.SOCIAL_SHARING_SETTINGS) + return share_settings.get('CERTIFICATE_LINKEDIN', enabled) + + def add_to_profile_url(self, course_name, cert_mode, cert_url, certificate=None): + """ + Construct the URL for the "add to profile" button. This will autofill the form based on + the params provided. + + Arguments: + course_name (str): The display name of the course. + cert_mode (str): The course mode of the user's certificate (e.g. "verified", "honor", "professional") + cert_url (str): The URL for the certificate. + + Keyword Arguments: + certificate (GeneratedCertificate): a GeneratedCertificate object for the user and course. + If provided, this function will also autofill the certId and issue date for the cert. + """ + params = { + 'name': self._cert_name(course_name, cert_mode), + 'certUrl': cert_url, + } + + params.update(self._organization_information()) + + if certificate: + params.update({ + 'certId': certificate.verify_uuid, + 'issueYear': certificate.created_date.year, + 'issueMonth': certificate.created_date.month, + }) + + return 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&{params}'.format( + params=urlencode(params) + ) + + def _cert_name(self, course_name, cert_mode): + """ + 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(cert_mode, _('{platform_name} Certificate for {course_name}')) + # 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( + platform_name=configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), + course_name=course_name + ) + + def _organization_information(self): + """ + Returns organization information for use in the URL parameters for add to profile. + + Returns: + dict: Either the organization ID on LinkedIn or the organization's name + Will be used to prefill the organization on the add to profile action. + """ + org_id = configuration_helpers.get_value('LINKEDIN_COMPANY_ID', self.company_identifier) + # Prefer organization ID per documentation at https://addtoprofile.linkedin.com/ + if org_id: + return {'organizationId': org_id} + return {'organizationName': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)} + + +class EntranceExamConfiguration(models.Model): + """ + Represents a Student's entrance exam specific data for a single Course + + .. no_pii: + """ + + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + 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: + unique_together = (('user', 'course_id'), ) + + def __str__(self): + return "[EntranceExamConfiguration] {}: {} ({}) = {}".format( + 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 + if ENTRANCE_EXAMS.is_enabled(): + 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 + + +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().__init__( + max_length=16, + choices=settings.ALL_LANGUAGES, + help_text=help_text, + *args, + **kwargs + ) + + +class LanguageProficiency(models.Model): + """ + Represents a user's language proficiency. + + 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 + /edx-platform/openedx/core/djangoapps/user_api/accounts/views.py or its associated api method + (update_account_settings) so that the events are emitted. + + .. no_pii: Language is not PII value according to OEP-30. + """ + class Meta: + unique_together = (('code', 'user_profile'),) + + user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='language_proficiencies', + on_delete=models.CASCADE) + code = models.CharField( + max_length=16, + blank=False, + choices=settings.ALL_LANGUAGES, + help_text=_("The ISO 639-1 language code for this language.") + ) + + +class SocialLink(models.Model): + """ + Represents a URL connecting a particular social platform to a user's social profile. + + The platforms are listed in the lms/common.py file under SOCIAL_PLATFORMS. + Each entry has a display name, a url_stub that describes a required + component of the stored URL and an example of a valid URL. + + The stored social_link value must adhere to the form 'https://www.[url_stub][username]'. + + .. pii: Stores linkage from User to a learner's social media profiles. Retired in AccountRetirementView. + .. pii_types: external_service + .. pii_retirement: local_api + """ + user_profile = models.ForeignKey(UserProfile, db_index=True, related_name='social_links', on_delete=models.CASCADE) + platform = models.CharField(max_length=30) + social_link = models.CharField(max_length=100, blank=True) + + +class RegistrationCookieConfiguration(ConfigurationModel): + """ + Configuration for registration cookies. + + .. no_pii: + """ + 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 __str__(self): + """Unicode representation of this config. """ + return "UTM: {utm_name}; AFFILIATE: {affiliate_name}".format( + utm_name=self.utm_cookie_name, + affiliate_name=self.affiliate_cookie_name + ) + + +class UserAttribute(TimeStampedModel): + """ + Record additional metadata about a user, stored as key/value pairs of text. + + .. no_pii: + """ + + class Meta: + # Ensure that at most one value exists for a given user/name. + unique_together = (('user', 'name',), ) + + user = models.ForeignKey(User, related_name='attributes', on_delete=models.CASCADE) + name = models.CharField(max_length=255, help_text=_("Name of this user attribute."), db_index=True) + value = models.CharField(max_length=255, help_text=_("Value of this user attribute.")) + + def __str__(self): + return "[{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.update_or_create(user=user, name=name, defaults={'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 + + +class AccountRecoveryManager(models.Manager): + """ + Custom Manager for AccountRecovery model + """ + + def get_active(self, **filters): + """ + Return only active AccountRecovery record after applying the given filters. + + Arguments: + filters (**kwargs): Filter parameters for AccountRecovery records. + + Returns: + AccountRecovery: AccountRecovery object with is_active=true + """ + filters['is_active'] = True + return super().get_queryset().get(**filters) + + def activate(self): + """ + Set is_active flag to True. + """ + super().get_queryset().update(is_active=True) + + +class AccountRecovery(models.Model): + """ + Model for storing information for user's account recovery in case of access loss. + + .. pii: the field named secondary_email contains pii, retired in the `DeactivateLogoutView` + .. pii_types: email_address + .. pii_retirement: local_api + """ + user = models.OneToOneField(User, related_name='account_recovery', on_delete=models.CASCADE) + secondary_email = models.EmailField( + verbose_name=_('Secondary email address'), + help_text=_('Secondary email address to recover linked account.'), + unique=True, + null=False, + blank=False, + ) + is_active = models.BooleanField(default=False) + + class Meta: + db_table = "auth_accountrecovery" + + objects = AccountRecoveryManager() + + def update_recovery_email(self, email): + """ + Update the secondary email address on the instance to the email in the argument. + + Arguments: + email (str): New email address to be set as the secondary email address. + """ + self.secondary_email = email + self.is_active = True + self.save() + + @classmethod + def retire_recovery_email(cls, user_id): + """ + Retire user's recovery/secondary email as part of GDPR Phase I. + Returns 'True' + + If an AccountRecovery record is found for this user it will be deleted, + if it is not found it is assumed this table has no PII for the given user. + + :param user_id: int + :return: bool + """ + try: + cls.objects.get(user_id=user_id).delete() + except cls.DoesNotExist: + pass + + return True + + +class AllowedAuthUser(TimeStampedModel): + site = models.ForeignKey(Site, related_name='allowed_auth_users', on_delete=models.CASCADE) + email = models.EmailField( + help_text=_( + "An employee (a user whose email has current site's domain name) whose email exists in this model, can be " + "able to login from login screen through email and password. And if any employee's email doesn't exist in " + "this model then that employee can login via third party authentication backend only."), + unique=True, + ) + + +class AccountRecoveryConfiguration(ConfigurationModel): + """ + configuration model for recover account management command + """ + csv_file = models.FileField( + validators=[FileExtensionValidator(allowed_extensions=['csv'])], + help_text=_("It expect that the data will be provided in a csv file format with \ + first row being the header and columns will be as follows: \ + username, current_email, desired_email") + ) + + +class UserCelebration(TimeStampedModel): + """ + Keeps track of how we've celebrated a user's progress on the platform. + This class is for course agnostic celebrations (not specific to a particular enrollment). + CourseEnrollmentCelebration is for celebrations that happen separately for each separate course. + + .. no_pii: + """ + user = models.OneToOneField(User, models.CASCADE, related_name='celebration') + # The last_day_of_streak and streak_length fields are used to + # control celebration of the streak feature. + # A streak is when a learner visits the learning MFE N days in a row. + # The business logic of streaks for a 3 day streak and 1 day break is the following: + # 1. Each streak should be celebrated exactly once, once the learner has completed the streak. + # 2. If a learner misses enough days to count as a break, the streak resets back to 0. + # 3. The streak is measured against the learner's configured timezone + # 4. We keep track of the total length of the streak, so there is a possibility in the future + # to add multiple celebrations for longer streaks. + # 5. We keep track of the longest_ever_streak field for potential future use for badging purposes. + last_day_of_streak = models.DateField(default=None, null=True, blank=True) + streak_length = models.IntegerField(default=0) + longest_ever_streak = models.IntegerField(default=0) + STREAK_LENGTHS_TO_CELEBRATE = [3] + STREAK_BREAK_LENGTH = 1 + + def __str__(self): + return ( + '[UserCelebration] user: {}; last_day_of_streak {}; streak_length {}; longest_ever_streak {};' + ).format(self.user.username, self.last_day_of_streak, self.streak_length, self.longest_ever_streak) + + @classmethod + def _get_now(cls, browser_timezone): + """ Retrieve the value for the current datetime in the user's timezone + + Once a user visits the learning MFE, their streak will not increment until midnight in their timezone. + The decision was to use the user's timezone and not UTC, to make each day of the streak more closely + correspond to separate days for the user. + The learning MFE passes in the browser timezone which is used as a fallback option if the user's timezone + in their account is not set. + UTC is used as a final fallback if neither timezone is set. + """ + # importing here to avoid a circular import + from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs + user_timezone_locale = user_timezone_locale_prefs(crum.get_current_request()) + user_timezone = timezone(user_timezone_locale['user_timezone'] or browser_timezone or str(UTC)) + return user_timezone.localize(datetime.now()) + + def _calculate_streak_updates(self, today): + """ Calculate the updates that should be applied to the streak fields of the provided celebration + A streak is incremented once for each day that a learner accesses the learning MFE. + A break is the amount of time that needs to pass before we stop incrementing the + existing streak and start a brand new streak. + See the UserCelebrationTests class for examples that should help clarify this behavior. + """ + last_day_of_streak = self.last_day_of_streak + streak_length = self.streak_length + streak_length_to_celebrate = None + + first_ever_streak = last_day_of_streak is None + break_length = timedelta(days=self.STREAK_BREAK_LENGTH) + should_start_new_streak = last_day_of_streak and last_day_of_streak + break_length < today + already_updated_streak_today = last_day_of_streak == today + + last_day_of_streak = today + if first_ever_streak or should_start_new_streak: + # Start new streak + streak_length = 1 + elif not already_updated_streak_today: + streak_length += 1 + if streak_length in self.STREAK_LENGTHS_TO_CELEBRATE: + # Celebrate if we didn't already celebrate today + streak_length_to_celebrate = streak_length + + return last_day_of_streak, streak_length, streak_length_to_celebrate + + def _update_streak(self, last_day_of_streak, streak_length): + """ Update the celebration with the new streak data """ + # If anything needs to be updated, update the celebration in the database + if last_day_of_streak != self.last_day_of_streak: + self.last_day_of_streak = last_day_of_streak + self.streak_length = streak_length + self.longest_ever_streak = max(self.longest_ever_streak, streak_length) + + self.save() + + @classmethod + def _get_celebration(cls, user, course_key): + """ Retrieve (or create) the celebration for the provided user and course_key """ + try: + # Only enable the streak if milestones and the streak are enabled for this course + if not streak_celebration_is_active(course_key): + return None + return user.celebration + except (cls.DoesNotExist, User.celebration.RelatedObjectDoesNotExist): # pylint: disable=no-member + celebration, _ = UserCelebration.objects.get_or_create(user=user) + return celebration + + @classmethod + def perform_streak_updates(cls, user, course_key, browser_timezone=None): + """ Determine if the user should see a streak celebration and + return the length of the streak the user should celebrate. + Also update the streak data that is stored in the database.""" + # importing here to avoid a circular import + from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student + if not user or user.is_anonymous: + return None + + if is_masquerading_as_specific_student(user, course_key): + return None + + celebration = cls._get_celebration(user, course_key) + + if not celebration: + return None + + today = cls._get_now(browser_timezone).date() + + # pylint: disable=protected-access + last_day_of_streak, streak_length, streak_length_to_celebrate = \ + celebration._calculate_streak_updates(today) + # pylint: enable=protected-access + + cls._update_streak(celebration, last_day_of_streak, streak_length) + + return streak_length_to_celebrate + + +class UserPasswordToggleHistory(TimeStampedModel): + """ + Keeps track of user password disable/enable history + """ + user = models.ForeignKey(User, related_name='password_toggle_history', on_delete=models.CASCADE) + comment = models.CharField(max_length=255, help_text=_("Add a reason"), blank=True, null=True) + disabled = models.BooleanField(default=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + ordering = ['-created'] + + def __str__(self): + return self.comment diff --git a/common/djangoapps/student/tests/test_activate_account.py b/common/djangoapps/student/tests/test_activate_account.py index e7d9bf16c34c..2ef5bc65ab15 100644 --- a/common/djangoapps/student/tests/test_activate_account.py +++ b/common/djangoapps/student/tests/test_activate_account.py @@ -77,7 +77,7 @@ def assert_no_tracking(self, mock_segment_identify): assert self.user.is_active assert not mock_segment_identify.called - @patch('common.djangoapps.student.models.USER_ACCOUNT_ACTIVATED') + @patch('common.djangoapps.student.models.student.USER_ACCOUNT_ACTIVATED') def test_activation_signal(self, mock_signal): """ Verify that USER_ACCOUNT_ACTIVATED is emitted upon account email activation. diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 520f5a594e5e..d345fde84a37 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -163,7 +163,7 @@ def test_external_course_updates_signal(self, value): """Confirm that we send the external updates experiment bucket with the activation signal""" with patch('openedx.core.djangoapps.schedules.config.set_up_external_updates_for_enrollment', return_value=value): - with patch('common.djangoapps.student.models.segment') as mock_segment: + with patch('common.djangoapps.student.models.course_enrollment.segment') as mock_segment: CourseEnrollment.enroll(self.user, self.course.id) assert mock_segment.track.call_count == 1 @@ -171,7 +171,7 @@ def test_external_course_updates_signal(self, value): assert mock_segment.track.call_args[0][2]['external_course_updates'] == value def test_enrollment_properties_in_segment_traits(self): - with patch('common.djangoapps.student.models.segment') as mock_segment: + with patch('common.djangoapps.student.models.course_enrollment.segment') as mock_segment: enrollment = CourseEnrollment.enroll(self.user, self.course.id) assert mock_segment.track.call_count == 1 assert mock_segment.track.call_args[0][1] == 'edx.course.enrollment.activated' @@ -180,7 +180,7 @@ def test_enrollment_properties_in_segment_traits(self): assert traits['mode'] == 'audit' assert traits['email'] == self.EMAIL - with patch('common.djangoapps.student.models.segment') as mock_segment: + with patch('common.djangoapps.student.models.course_enrollment.segment') as mock_segment: enrollment.update_enrollment(mode='verified') assert mock_segment.track.call_count == 1 assert mock_segment.track.call_args[0][1] == 'edx.course.enrollment.mode_changed' @@ -242,7 +242,7 @@ def test_enroll_in_proctored_course(self, mode, email_sent): requirements should be sent. The email should not be sent for non-verified modes. """ with patch( - 'common.djangoapps.student.models.send_proctoring_requirements_email', + 'common.djangoapps.student.emails.send_proctoring_requirements_email', return_value=None ) as mock_send_email: # First enroll in a non-proctored course. This should not trigger the email. @@ -259,7 +259,7 @@ def test_enroll_in_proctored_course_no_exam(self): any proctored exams, they should not receive a proctoring requirements email. """ with patch( - 'common.djangoapps.student.models.send_proctoring_requirements_email', + 'common.djangoapps.student.emails.send_proctoring_requirements_email', return_value=None ) as mock_send_email: CourseEnrollment.enroll( @@ -274,7 +274,7 @@ def test_upgrade_proctoring_enrollment(self, mode): should be sent. """ with patch( - 'common.djangoapps.student.models.send_proctoring_requirements_email', + 'common.djangoapps.student.emails.send_proctoring_requirements_email', return_value=None ) as mock_send_email: enrollment = CourseEnrollment.enroll( @@ -293,7 +293,7 @@ def test_enroll_in_proctored_course_honor_mode_allowed(self): enroll in honor mode for a course with proctored exams. """ with patch( - 'common.djangoapps.student.models.send_proctoring_requirements_email', + 'common.djangoapps.student.emails.send_proctoring_requirements_email', return_value=None ) as mock_send_email: course_honor_mode = CourseFactory( diff --git a/common/djangoapps/student/tests/test_events.py b/common/djangoapps/student/tests/test_events.py index a8b70ea4b889..332aa5700387 100644 --- a/common/djangoapps/student/tests/test_events.py +++ b/common/djangoapps/student/tests/test_events.py @@ -93,7 +93,7 @@ def test_excluded_field(self): self.profile.save() self.assert_no_events_were_emitted() - @mock.patch('common.djangoapps.student.models.UserProfile.save', side_effect=IntegrityError) + @mock.patch('common.djangoapps.student.models.student.UserProfile.save', side_effect=IntegrityError) def test_no_event_if_save_failed(self, _save_mock): """ Verify no event is triggered if the save does not complete. Note that the pre_save diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py index fd90a2f37e4a..527d5f5776dd 100644 --- a/common/djangoapps/student/tests/test_models.py +++ b/common/djangoapps/student/tests/test_models.py @@ -808,7 +808,7 @@ def test_is_marketable_set_to_false_for_user_created_via_management_command(self last_name='Person', email='some.user@example.com', ) - with mock.patch('common.djangoapps.student.models.segment') as mock_segment: + with mock.patch('common.djangoapps.student.models.student.segment') as mock_segment: user._called_by_management_command = True # pylint: disable=protected-access user.save() diff --git a/common/djangoapps/student/tests/test_refunds.py b/common/djangoapps/student/tests/test_refunds.py index 54fef6ce9bdc..8de6267403d6 100644 --- a/common/djangoapps/student/tests/test_refunds.py +++ b/common/djangoapps/student/tests/test_refunds.py @@ -33,6 +33,7 @@ log = logging.getLogger(__name__) TEST_API_URL = 'http://www-internal.example.com/api' JSON = 'application/json' +ENROLLMENT_REFUND_CONFIG = 'common.djangoapps.student.models.course_enrollment.EnrollmentRefundConfiguration.current' @ddt.ddt @@ -65,13 +66,13 @@ def setUp(self): self.client = Client() cache.clear() - @patch('common.djangoapps.student.models.CourseEnrollment.refund_cutoff_date') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date') def test_refundable(self, cutoff_date): """ Assert base case is refundable""" cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1) assert self.enrollment.refundable() - @patch('common.djangoapps.student.models.CourseEnrollment.refund_cutoff_date') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date') def test_refundable_expired_verification(self, cutoff_date): """ Assert that enrollment is refundable if course mode has expired.""" cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1) @@ -79,7 +80,7 @@ def test_refundable_expired_verification(self, cutoff_date): self.verified_mode.save() assert self.enrollment.refundable() - @patch('common.djangoapps.student.models.CourseEnrollment.refund_cutoff_date') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date') def test_refundable_when_certificate_exists(self, cutoff_date): """ Assert that enrollment is not refundable once a certificat has been generated.""" @@ -108,7 +109,7 @@ def test_refundable_when_certificate_exists(self, cutoff_date): self.enrollment.can_refund = True assert self.enrollment.refundable() - @patch('common.djangoapps.student.models.CourseEnrollment.refund_cutoff_date') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refund_cutoff_date') def test_refundable_with_cutoff_date(self, cutoff_date): """ Assert enrollment is refundable before cutoff and not refundable after.""" cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(days=1) @@ -156,7 +157,7 @@ def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected value=self.ORDER_NUMBER ) - with patch('common.djangoapps.student.models.EnrollmentRefundConfiguration.current') as config: + with patch(ENROLLMENT_REFUND_CONFIG) as config: instance = config.return_value instance.refund_window = refund_period assert self.enrollment.refund_cutoff_date() == (expected_date + refund_period) diff --git a/common/djangoapps/student/tests/test_retirement.py b/common/djangoapps/student/tests/test_retirement.py index 293bdb0f5a75..a7e5fac86571 100644 --- a/common/djangoapps/student/tests/test_retirement.py +++ b/common/djangoapps/student/tests/test_retirement.py @@ -15,8 +15,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_lms from common.djangoapps.student.models import ( - _get_all_retired_emails_by_email, - _get_all_retired_usernames_by_username, + get_all_retired_usernames_by_username, get_potentially_retired_user_by_username, get_potentially_retired_user_by_username_and_hash, get_retired_email_by_email, @@ -112,7 +111,7 @@ def test_get_all_retired_usernames_by_username(retirement_user): # lint-amnesty Check that all salts are used for this method and return expected formats. """ - hashed_usernames = list(_get_all_retired_usernames_by_username(retirement_user.username)) + hashed_usernames = list(get_all_retired_usernames_by_username(retirement_user.username)) assert len(hashed_usernames) == len(settings.RETIRED_USER_SALTS) for hashed_username in hashed_usernames: @@ -182,21 +181,6 @@ def test_get_retired_email_status_exists(retirement_user, retirement_status): # assert retirement_status.retired_email == hashed_email -def test_get_all_retired_email_by_email(retirement_user): # lint-amnesty, pylint: disable=redefined-outer-name - """ - Check that all salts are used for this method and return expected - formats. - """ - hashed_emails = list(_get_all_retired_emails_by_email(retirement_user.email)) - assert len(hashed_emails) == len(settings.RETIRED_USER_SALTS) - - for hashed_email in hashed_emails: - check_email_against_fmt(hashed_email) - - # Make sure hashes are unique - assert len(hashed_emails) == len(set(hashed_emails)) - - def test_get_correct_user_varying_by_case_only(two_users_same_username_different_case): # lint-amnesty, pylint: disable=redefined-outer-name """ Check that two users - one retired, one active - with the same username except for case can be found. diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index e42ef17fd0f1..1c725aef5347 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -54,6 +54,9 @@ THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) THREE_YEARS_AGO = now() - timedelta(days=(365 * 3)) +# Name of the method to mock for having a course be refundable or not +REFUNDABLE_METHOD_NAME = 'common.djangoapps.student.models.course_enrollment.CourseEnrollment.refundable' + # Name of the method to mock for Content Type Gating. GATING_METHOD_NAME = 'openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment' @@ -150,13 +153,13 @@ def test_cant_unenroll_status(self): def test_course_run_refund_status_successful(self): """ Assert that view:course_run_refund_status returns correct Json for successful refund call.""" - with patch('common.djangoapps.student.models.CourseEnrollment.refundable', return_value=True): + with patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.refundable', return_value=True): response = self.client.get(reverse('course_run_refund_status', kwargs={'course_id': self.course.id})) assert json.loads(response.content.decode('utf-8')) == {'course_refundable_status': True} assert response.status_code == 200 - with patch('common.djangoapps.student.models.CourseEnrollment.refundable', return_value=False): + with patch(REFUNDABLE_METHOD_NAME, return_value=False): response = self.client.get(reverse('course_run_refund_status', kwargs={'course_id': self.course.id})) assert json.loads(response.content.decode('utf-8')) == {'course_refundable_status': False} diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index b476126a1e5a..e8c5bb9e73a3 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -55,7 +55,12 @@ log = logging.getLogger(__name__) BETA_TESTER_METHOD = 'common.djangoapps.student.helpers.access.is_beta_tester' - +EXTRA_SEG_PROPERTIES = \ +{ + 'studio_request': False, + 'exception_raised': False, + 'redesign_email': False +} @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @ddt.ddt @@ -624,8 +629,8 @@ def assert_user_enrollment_occurred(self, course_key): class EnrollmentEventTestMixin(EventTestMixin): """ Mixin with assertions for validating enrollment events. """ def setUp(self): # lint-amnesty, pylint: disable=arguments-differ - super().setUp('common.djangoapps.student.models.tracker') - segment_patcher = patch('common.djangoapps.student.models.segment') + super().setUp('common.djangoapps.student.models.course_enrollment.tracker') + segment_patcher = patch('common.djangoapps.student.models.course_enrollment.segment') self.mock_segment_tracker = segment_patcher.start() self.addCleanup(segment_patcher.stop) @@ -646,7 +651,7 @@ def assert_enrollment_mode_change_event_was_emitted(self, user, course_key, mode ) self.mock_segment_tracker.reset_mock() - def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment): + def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment, extra_seg_properties = None): """Ensures an enrollment event was emitted since the last event related assertion""" self.mock_tracker.emit.assert_called_once_with( 'edx.course.enrollment.activated', @@ -658,6 +663,8 @@ def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollme ) self.mock_tracker.reset_mock() properties, traits = self._build_segment_properties_and_traits(user, course_key, course, enrollment, True) + if (extra_seg_properties): + properties.update(extra_seg_properties) self.mock_segment_tracker.track.assert_called_once_with( user.id, 'edx.course.enrollment.activated', properties, traits=traits ) @@ -721,7 +728,7 @@ def test_enrollment(self): enrollment = CourseEnrollment.enroll(user, course_id) assert CourseEnrollment.is_enrolled(user, course_id) assert CourseEnrollment.is_enrolled_by_partial(user, course_id_partial) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, EXTRA_SEG_PROPERTIES) # Enrolling them again should be harmless enrollment = CourseEnrollment.enroll(user, course_id) @@ -772,7 +779,7 @@ def test_enrollment_non_existent_user(self): # should still work enrollment = CourseEnrollment.enroll(user, course_id) assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, EXTRA_SEG_PROPERTIES) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_enrollment_by_email(self): @@ -782,7 +789,7 @@ def test_enrollment_by_email(self): enrollment = CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id) assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, EXTRA_SEG_PROPERTIES) # This won't throw an exception, even though the user is not found assert CourseEnrollment.enroll_by_email('not_jack@fake.edx.org', course_id) is None @@ -820,9 +827,9 @@ def test_enrollment_multiple_classes(self): course2 = CourseOverviewFactory.create(id=course_id2) enrollment1 = CourseEnrollment.enroll(user, course_id1) - self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1) + self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1, EXTRA_SEG_PROPERTIES) enrollment2 = CourseEnrollment.enroll(user, course_id2) - self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2) + self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2, EXTRA_SEG_PROPERTIES) assert CourseEnrollment.is_enrolled(user, course_id1) assert CourseEnrollment.is_enrolled(user, course_id2) @@ -852,7 +859,7 @@ def test_activation(self): # Until you explicitly activate it enrollment.activate() assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, EXTRA_SEG_PROPERTIES) # Activating something that's already active does nothing enrollment.activate() @@ -873,15 +880,18 @@ def test_activation(self): # for that user/course_id combination CourseEnrollment.enroll(user, course_id) assert CourseEnrollment.is_enrolled(user, course_id) - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, EXTRA_SEG_PROPERTIES) + """ + transition through various modes, verifying that change events are emitted when appropriate + """ def test_change_enrollment_modes(self): user = UserFactory.create(username="justin", email="jh@fake.edx.org") course_id = CourseLocator("edX", "Test101", "2013") course = CourseOverviewFactory.create(id=course_id) enrollment = CourseEnrollment.enroll(user, course_id, "audit") - self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment) + self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, EXTRA_SEG_PROPERTIES) enrollment = CourseEnrollment.enroll(user, course_id, "honor") self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "honor", course, enrollment) @@ -990,7 +1000,7 @@ def setUp(self): mode_display_name='Honor Code', ) self.user2 = UserFactory.create() - patcher = patch('common.djangoapps.student.models.tracker') + patcher = patch('common.djangoapps.student.models.course_enrollment.tracker') patcher.start() self.addCleanup(patcher.stop) diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 9bf6c0c8fe99..55a1beff85aa 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/lms/djangoapps/branding/tests/test_views.py b/lms/djangoapps/branding/tests/test_views.py index 874d302658d2..d0ba14c7166f 100644 --- a/lms/djangoapps/branding/tests/test_views.py +++ b/lms/djangoapps/branding/tests/test_views.py @@ -256,7 +256,7 @@ def setUp(self): """ Set up a user """ super().setUp() - patcher = mock.patch("common.djangoapps.student.models.tracker") + patcher = mock.patch("common.djangoapps.student.models.course_enrollment.tracker") self.mock_tracker = patcher.start() self.user = UserFactory.create() self.user.set_password("password") diff --git a/lms/djangoapps/certificates/tests/test_models.py b/lms/djangoapps/certificates/tests/test_models.py index a5623154ea6f..cd778bb8d8f2 100644 --- a/lms/djangoapps/certificates/tests/test_models.py +++ b/lms/djangoapps/certificates/tests/test_models.py @@ -41,7 +41,7 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -ENROLLMENT_METHOD = 'common.djangoapps.student.models.CourseEnrollment.enrollment_mode_for_user' +ENROLLMENT_METHOD = 'common.djangoapps.student.models.course_enrollment.CourseEnrollment.enrollment_mode_for_user' PROFILE_METHOD = 'common.djangoapps.student.models_api.get_name' FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy() diff --git a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py index ea8ba99c16e9..e6a017ca2c85 100644 --- a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py +++ b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py @@ -100,7 +100,8 @@ def test_streak_data_in_response(self): """ Test that metadata endpoint returns data for the streak celebration """ CourseEnrollment.enroll(self.user, self.course.id, 'audit') with override_waffle_flag(COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT, active=True): - with mock.patch('common.djangoapps.student.models.UserCelebration.perform_streak_updates', return_value=3): + with mock.patch('common.djangoapps.student.models.student.UserCelebration.perform_streak_updates', + return_value=3): response = self.client.get(self.url, content_type='application/json') celebrations = response.json()['celebrations'] assert celebrations['streak_length_to_celebrate'] == 3 diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index fd8b9be2127b..3c1b1be01e2d 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -46,6 +46,8 @@ from xmodule.modulestore.tests.utils import TEST_DATA_DIR # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order +IS_ENROLLED_METHOD = 'common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_enrolled' + class TabTestCase(SharedModuleStoreTestCase): """Base class for Tab-related test cases.""" @@ -705,7 +707,7 @@ def check_progress_tab(self): invalid_dict_tab=None, ) - @patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_enrolled') def test_progress(self, is_enrolled): is_enrolled.return_value = True self.course.hide_progress_tab = False @@ -761,7 +763,7 @@ def check_discussion( is_enrolled=True ): """Helper function to verify whether the discussion tab exists and can be displayed""" - with patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled') as check_is_enrolled: + with patch(IS_ENROLLED_METHOD) as check_is_enrolled: self.course.tabs = tab_list self.course.discussion_link = discussion_link_in_course discussion_tab = xmodule_tabs.CourseTabList.get_discussion(self.course) @@ -849,7 +851,7 @@ def test_tab_link(self, toggle_enabled): class DatesTabTestCase(TabListTestCase): """Test cases for dates tab""" - @patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_enrolled') def test_dates_tab_disabled_if_unenrolled(self, is_enrolled): tab = DatesTab({'type': DatesTab.type, 'name': 'dates'}) diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index a56612640380..1bc5ca4247be 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -138,7 +138,8 @@ def call_view( "group_id": self.student_cohort.id, "closed": False, "type": "thread", - "commentable_id": "non_team_dummy_id" + "commentable_id": "non_team_dummy_id", + "body": "test body" } ) request = RequestFactory().post("dummy_url", post_params or {}) @@ -237,7 +238,7 @@ def set_up_course(self, module_count=0): # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user - with patch('common.djangoapps.student.models.cc.User.save'): + with patch('common.djangoapps.student.models.student.cc.User.save'): uname = 'student' email = 'student@edx.org' self.password = 'test' @@ -458,7 +459,7 @@ def setUp(self): # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user - with patch('common.djangoapps.student.models.cc.User.save'): + with patch('common.djangoapps.student.models.student.cc.User.save'): uname = 'student' email = 'student@edx.org' self.password = 'test' @@ -1663,7 +1664,7 @@ def test_comment_actions(self, user, commentable_id, status_code, mock_request): commentable_id = getattr(self, commentable_id) self._setup_mock( user, mock_request, - {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"}, + {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread", "body": 'dummy body'}, ) for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]: response = self.client.post( @@ -1684,7 +1685,7 @@ def test_threads_actions(self, user, commentable_id, status_code, mock_request): commentable_id = getattr(self, commentable_id) self._setup_mock( user, mock_request, - {"closed": False, "commentable_id": commentable_id}, + {"closed": False, "commentable_id": commentable_id, "body": "dummy body"}, ) for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", "follow_thread", "unfollow_thread"]: diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index 6072ebd902a5..29bf054cd539 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -270,8 +270,11 @@ def track_thread_reported_event(request, course, thread): event_name = _EVENT_NAME_TEMPLATE.format(obj_type='thread', action_name='reported') event_data = { 'body': thread.body[:TRACKING_MAX_FORUM_BODY], + 'truncated': len(thread.body) > TRACKING_MAX_FORUM_BODY, 'content_type': 'Post', 'commentable_id': thread.get('commentable_id', ''), + 'thread_type': thread.get('thread_type', ''), + 'group_id': thread.get('group_id', ''), } if hasattr(thread, 'username'): event_data['target_username'] = thread.get('username', '') @@ -287,6 +290,7 @@ def track_comment_reported_event(request, course, comment): event_name = _EVENT_NAME_TEMPLATE.format(obj_type=obj_type, action_name='reported') event_data = { 'body': comment.body[:TRACKING_MAX_FORUM_BODY], + 'truncated': len(comment.body) > TRACKING_MAX_FORUM_BODY, 'commentable_id': comment.get('commentable_id', ''), 'content_type': obj_type.capitalize(), } @@ -302,9 +306,13 @@ def track_thread_unreported_event(request, course, thread): event_name = _EVENT_NAME_TEMPLATE.format(obj_type='thread', action_name='unreported') event_data = { 'body': thread.body[:TRACKING_MAX_FORUM_BODY], + 'truncated': len(thread.body) > TRACKING_MAX_FORUM_BODY, 'content_type': 'Post', 'commentable_id': thread.get('commentable_id', ''), 'reported_status_cleared': not bool(thread.get('abuse_flaggers', [])), + 'thread_type': thread.get('thread_type', ''), + 'group_id': thread.get('group_id', ''), + } if hasattr(thread, 'username'): event_data['target_username'] = thread.get('username', '') @@ -320,6 +328,7 @@ def track_comment_unreported_event(request, course, comment): event_name = _EVENT_NAME_TEMPLATE.format(obj_type=obj_type, action_name='unreported') event_data = { 'body': comment.body[:TRACKING_MAX_FORUM_BODY], + 'truncated': len(comment.body) > TRACKING_MAX_FORUM_BODY, 'commentable_id': comment.get('commentable_id', ''), 'content_type': obj_type.capitalize(), 'reported_status_cleared': not bool(comment.get('abuse_flaggers', [])), @@ -767,9 +776,10 @@ def flag_abuse_for_thread(request, course_id, thread_id): """ course_key = CourseKey.from_string(course_id) user = cc.User.from_django_user(request.user) + course = get_course_by_id(course_key) thread = cc.Thread.find(thread_id) thread.flagAbuse(user, thread) - + track_discussion_reported_event(request, course, thread) return JsonResponse(prepare_content(thread.to_dict(), course_key)) @@ -790,7 +800,7 @@ def un_flag_abuse_for_thread(request, course_id, thread_id): has_access(request.user, 'staff', course) ) thread.unFlagAbuse(user, thread, remove_all) - + track_discussion_unreported_event(request, course, thread) return JsonResponse(prepare_content(thread.to_dict(), course_key)) @@ -804,8 +814,10 @@ def flag_abuse_for_comment(request, course_id, comment_id): """ course_key = CourseKey.from_string(course_id) user = cc.User.from_django_user(request.user) + course = get_course_by_id(course_key) comment = cc.Comment.find(comment_id) comment.flagAbuse(user, comment) + track_discussion_unreported_event(request, course, comment) return JsonResponse(prepare_content(comment.to_dict(), course_key)) @@ -826,6 +838,7 @@ def un_flag_abuse_for_comment(request, course_id, comment_id): ) comment = cc.Comment.find(comment_id) comment.unFlagAbuse(user, comment, remove_all) + track_discussion_unreported_event(request, course, comment) return JsonResponse(prepare_content(comment.to_dict(), course_key)) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 15e6fb8cfb05..aab2be06c27a 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2871,6 +2871,9 @@ def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): 'target_username': self.user.username, 'title_truncated': False, 'title': 'Original Title', + 'thread_type': 'discussion', + 'group_id': None, + 'truncated': False, } if not new_flagged: expected_event_data['reported_status_cleared'] = False @@ -2919,6 +2922,9 @@ def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mo 'title_truncated': False, 'title': 'Original Title', 'reported_status_cleared': False, + 'thread_type': 'discussion', + 'group_id': None, + 'truncated': False, } actual_event_name, actual_event_data = mock_emit.call_args[0] @@ -3433,6 +3439,7 @@ def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): 'content_type': 'Response', 'commentable_id': 'dummy', 'url': '', + 'truncated': False, 'user_course_roles': [], 'user_forums_roles': [FORUM_ROLE_STUDENT], 'target_username': self.user.username, @@ -3477,6 +3484,7 @@ def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all, m 'id': 'test_comment', 'content_type': 'Response', 'commentable_id': 'dummy', + 'truncated': False, 'url': '', 'user_course_roles': [], 'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index c28ce016dfde..3ac2a4699c48 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -78,7 +78,7 @@ def setUpClass(cls): # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user - with mock.patch('common.djangoapps.student.models.cc.User.save'): + with mock.patch('common.djangoapps.student.models.student.cc.User.save'): cls.thread_author = UserFactory( username='thread_author', password='password', diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index c51d186cbd23..4e1a688326f9 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -91,7 +91,7 @@ def setUp(self): # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user - with patch('common.djangoapps.student.models.cc.User.save'): + with patch('common.djangoapps.student.models.student.cc.User.save'): uname = 'student' email = 'student@edx.org' password = 'test' @@ -110,8 +110,8 @@ def setUp(self): config.enabled = True config.save() - @patch('common.djangoapps.student.models.cc.User.from_django_user') - @patch('common.djangoapps.student.models.cc.User.active_threads') + @patch('common.djangoapps.student.models.student.cc.User.from_django_user') + @patch('common.djangoapps.student.models.student.cc.User.active_threads') def test_user_profile_exception(self, mock_threads, mock_from_django_user): # Mock the code that makes the HTTP requests to the cs_comment_service app @@ -127,8 +127,8 @@ def test_user_profile_exception(self, mock_threads, mock_from_django_user): response = self.client.get(url) assert response.status_code == 404 - @patch('common.djangoapps.student.models.cc.User.from_django_user') - @patch('common.djangoapps.student.models.cc.User.subscribed_threads') + @patch('common.djangoapps.student.models.student.cc.User.from_django_user') + @patch('common.djangoapps.student.models.student.cc.User.subscribed_threads') def test_user_followed_threads_exception(self, mock_threads, mock_from_django_user): # Mock the code that makes the HTTP requests to the cs_comment_service app @@ -1661,7 +1661,7 @@ def setUp(self): assert self.client.login(username=username, password=password) @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.cc.User.from_django_user') + @patch('common.djangoapps.student.models.student.cc.User.from_django_user') def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req): """ Test that XSS attack is prevented @@ -1677,8 +1677,8 @@ def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req) self.assertNotContains(resp, malicious_code) @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.cc.User.from_django_user') - @patch('common.djangoapps.student.models.cc.User.active_threads') + @patch('common.djangoapps.student.models.student.cc.User.from_django_user') + @patch('common.djangoapps.student.models.student.cc.User.active_threads') def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request): """ Test that XSS attack is prevented diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 6d87c8a80763..f0d7b64cfc62 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -30,6 +30,9 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +GET_USER_ENROLLMENT_MODE =\ + "common.djangoapps.student.models.course_enrollment.CourseEnrollment.enrollment_mode_for_user" + @ddt.ddt class TestAnalyticsBasic(ModuleStoreTestCase): @@ -182,7 +185,7 @@ def test_enrolled_students_enrollment_verification(self): assert userreport['verification_status'] in ['N/A'] # make sure that the user report respects whatever value # is returned by verification and enrollment code - with patch("common.djangoapps.student.models.CourseEnrollment.enrollment_mode_for_user") as enrollment_patch: + with patch(GET_USER_ENROLLMENT_MODE) as enrollment_patch: with patch( "lms.djangoapps.verify_student.services.IDVerificationService.verification_status_for_user" ) as verify_patch: diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py b/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py index 441b6ef91eaf..39e30207b2f7 100644 --- a/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py +++ b/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py @@ -81,7 +81,7 @@ def create_grade(self, user_id, course_id): @ddt.data( False, True ) - @patch('common.djangoapps.student.models.segment.track') + @patch('common.djangoapps.student.models.course_enrollment.segment.track') @patch('lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email.get_programs') @override_settings(FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True)) def test_email_send(self, add_no_commit, get_programs_mock, mock_track): @@ -113,7 +113,7 @@ def test_email_send(self, add_no_commit, get_programs_mock, mock_track): @ddt.data( False, True ) - @patch('common.djangoapps.student.models.segment.track') + @patch('common.djangoapps.student.models.course_enrollment.segment.track') @patch('lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email.get_programs') @override_settings(FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True)) def test_email_no_course_recommendation(self, add_no_commit, get_programs_mock, mock_track): diff --git a/lms/djangoapps/program_enrollments/tests/test_signals.py b/lms/djangoapps/program_enrollments/tests/test_signals.py index c7332bb06265..736d2fa446d2 100644 --- a/lms/djangoapps/program_enrollments/tests/test_signals.py +++ b/lms/djangoapps/program_enrollments/tests/test_signals.py @@ -378,7 +378,7 @@ def test_exception_on_enrollment_failure(self): program_enrollment = self._create_waiting_program_enrollment() self._create_waiting_course_enrollments(program_enrollment) - with mock.patch('common.djangoapps.student.models.CourseEnrollment.enroll') as enrollMock: + with mock.patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.enroll') as enrollMock: enrollMock.side_effect = CourseEnrollmentException('something has gone wrong') with pytest.raises(CourseEnrollmentException): UserSocialAuth.objects.create( diff --git a/openedx/core/djangoapps/course_live/tests/test_tab.py b/openedx/core/djangoapps/course_live/tests/test_tab.py index fd673ed30547..0ab2bdfeae6a 100644 --- a/openedx/core/djangoapps/course_live/tests/test_tab.py +++ b/openedx/core/djangoapps/course_live/tests/test_tab.py @@ -48,7 +48,7 @@ def check_course_live_tab(self): ) @ddt.data(True, False) - @patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled', Mock(return_value=True)) + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_enrolled', Mock(return_value=True)) def test_user_can_access_course_live_tab(self, course_live_config_enabled): """ Test if tab is accessible to users with different roles diff --git a/openedx/core/djangoapps/courseware_api/tests/test_views.py b/openedx/core/djangoapps/courseware_api/tests/test_views.py index 23593c86573b..d6e040b45967 100644 --- a/openedx/core/djangoapps/courseware_api/tests/test_views.py +++ b/openedx/core/djangoapps/courseware_api/tests/test_views.py @@ -301,7 +301,8 @@ def test_streak_data_in_response(self): """ Test that metadata endpoint returns data for the streak celebration """ CourseEnrollment.enroll(self.user, self.course.id, 'audit') with override_waffle_flag(COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT, active=True): - with mock.patch('common.djangoapps.student.models.UserCelebration.perform_streak_updates', return_value=3): + with mock.patch('common.djangoapps.student.models.student.UserCelebration.perform_streak_updates', + return_value=3): response = self.client.get(self.url, content_type='application/json') celebrations = response.json()['celebrations'] assert celebrations['streak_length_to_celebrate'] == 3 @@ -311,7 +312,8 @@ def test_streak_segment_suppressed_for_unverified(self): """ Test that metadata endpoint does not return a discount and signal is not sent if flag is not set """ CourseEnrollment.enroll(self.user, self.course.id, 'audit') with override_waffle_flag(COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT, active=False): - with mock.patch('common.djangoapps.student.models.UserCelebration.perform_streak_updates', return_value=3): + with mock.patch('common.djangoapps.student.models.student.UserCelebration.perform_streak_updates', + return_value=3): response = self.client.get(self.url, content_type='application/json') celebrations = response.json()['celebrations'] assert celebrations['streak_length_to_celebrate'] == 3 diff --git a/openedx/core/djangoapps/profile_images/tests/test_views.py b/openedx/core/djangoapps/profile_images/tests/test_views.py index 8f6194cd45af..85b4e3c1e9c1 100644 --- a/openedx/core/djangoapps/profile_images/tests/test_views.py +++ b/openedx/core/djangoapps/profile_images/tests/test_views.py @@ -441,7 +441,7 @@ def test_remove_staff(self, mock_log): ) self.check_remove_event_emitted() - @patch('common.djangoapps.student.models.UserProfile.save') + @patch('common.djangoapps.student.models.student.UserProfile.save') def test_remove_failure(self, user_profile_save, mock_log): """ Test that when remove validation fails, the proper HTTP response and diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index a205362b3b42..c0d44aeca221 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -4,12 +4,13 @@ import datetime +import ddt import hashlib import json import unicodedata -from unittest.mock import Mock, patch - -import ddt +from common.djangoapps.student.models import LoginFailures +from common.djangoapps.student.tests.factories import RegistrationFactory, UserFactory, UserProfileFactory +from common.djangoapps.util.password_policy_validators import DEFAULT_MAX_PASSWORD_LENGTH from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core import mail @@ -20,14 +21,12 @@ from django.urls import NoReverseMatch, reverse from edx_toggles.toggles.testutils import override_waffle_switch from freezegun import freeze_time -from common.djangoapps.student.tests.factories import RegistrationFactory, UserFactory, UserProfileFactory -from openedx_events.tests.utils import OpenEdxEventsTestMixin # lint-amnesty, pylint: disable=wrong-import-order - from openedx.core.djangoapps.password_policy.compliance import ( NonCompliantPasswordException, NonCompliantPasswordWarning ) from openedx.core.djangoapps.password_policy.hibp import PwnedPasswordsAPI +from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.user_api.accounts import EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH from openedx.core.djangoapps.user_authn.config.waffle import ENABLE_PWNED_PASSWORD_API from openedx.core.djangoapps.user_authn.cookies import jwt_cookies @@ -38,11 +37,10 @@ _check_user_auth_flow ) from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms -from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.lib.api.test_utils import ApiTestCase from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory -from common.djangoapps.student.models import LoginFailures -from common.djangoapps.util.password_policy_validators import DEFAULT_MAX_PASSWORD_LENGTH +from openedx_events.tests.utils import OpenEdxEventsTestMixin # lint-amnesty, pylint: disable=wrong-import-order +from unittest.mock import Mock, patch @ddt.ddt @@ -92,7 +90,7 @@ def _create_user(self, username, user_email): def test_login_success(self): response, mock_audit_log = self._login_response( - self.user_email, self.password, patched_audit_log='common.djangoapps.student.models.AUDIT_LOG' + self.user_email, self.password, patched_audit_log='common.djangoapps.student.models.student.AUDIT_LOG' ) self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', ['Login success', self.user_email]) @@ -105,7 +103,7 @@ def test_login_success_with_opt_in_flag_enabled(self): self.user.is_active = False self.user.save() response, mock_audit_log = self._login_response( - self.user_email, self.password, patched_audit_log='common.djangoapps.student.models.AUDIT_LOG' + self.user_email, self.password, patched_audit_log='common.djangoapps.student.models.student.AUDIT_LOG' ) self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', ['Login success', self.user_email]) @@ -318,7 +316,7 @@ def test_enterprise_in_url( @patch.dict("django.conf.settings.FEATURES", {'SQUELCH_PII_IN_LOGS': True}) def test_login_success_no_pii(self): response, mock_audit_log = self._login_response( - self.user_email, self.password, patched_audit_log='common.djangoapps.student.models.AUDIT_LOG' + self.user_email, self.password, patched_audit_log='common.djangoapps.student.models.student.AUDIT_LOG' ) self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', ['Login success']) @@ -330,7 +328,7 @@ def test_login_success_unicode_email(self): self.user.save() response, mock_audit_log = self._login_response( - unicode_email, self.password, patched_audit_log='common.djangoapps.student.models.AUDIT_LOG' + unicode_email, self.password, patched_audit_log='common.djangoapps.student.models.student.AUDIT_LOG' ) self._assert_response(response, success=True) self._assert_audit_log(mock_audit_log, 'info', ['Login success', unicode_email]) @@ -477,7 +475,7 @@ def test_logout_logging(self): response, _ = self._login_response(self.user_email, self.password) self._assert_response(response, success=True) logout_url = reverse('logout') - with patch('common.djangoapps.student.models.AUDIT_LOG') as mock_audit_log: + with patch('common.djangoapps.student.models.student.AUDIT_LOG') as mock_audit_log: response = self.client.post(logout_url) assert response.status_code == 200 self._assert_audit_log(mock_audit_log, 'info', ['Logout', 'test']) @@ -537,7 +535,7 @@ def test_logout_logging_no_pii(self): response, _ = self._login_response(self.user_email, self.password) self._assert_response(response, success=True) logout_url = reverse('logout') - with patch('common.djangoapps.student.models.AUDIT_LOG') as mock_audit_log: + with patch('common.djangoapps.student.models.student.AUDIT_LOG') as mock_audit_log: response = self.client.post(logout_url) assert response.status_code == 200 self._assert_audit_log(mock_audit_log, 'info', ['Logout']) diff --git a/openedx/features/enterprise_support/tests/test_signals.py b/openedx/features/enterprise_support/tests/test_signals.py index b4c6bc6604db..3207aeb024f3 100644 --- a/openedx/features/enterprise_support/tests/test_signals.py +++ b/openedx/features/enterprise_support/tests/test_signals.py @@ -133,7 +133,7 @@ def _create_enrollment_to_refund(self, no_of_days_placed=10, enterprise_enrollme return enrollment - @patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_order_voucher_refundable') @ddt.data( (True, True, 2, True, False), # test if skip_refund (False, True, 20, True, False), # test refundable time passed @@ -161,7 +161,7 @@ def test_refund_order_voucher( enrollment.update_enrollment(is_active=False, skip_refund=skip_refund) assert mock_ecommerce_api_client.called == api_called - @patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable') + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_order_voucher_refundable') @ddt.data( (HttpClientError, 'INFO'), (HttpServerError, 'ERROR'), diff --git a/openedx/features/lti_course_tab/tests.py b/openedx/features/lti_course_tab/tests.py index ce0f483b584d..13827f03bbfd 100644 --- a/openedx/features/lti_course_tab/tests.py +++ b/openedx/features/lti_course_tab/tests.py @@ -45,7 +45,7 @@ def check_discussion_tab(self): ) @ddt.data(True, False) - @patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled', Mock(return_value=True)) + @patch('common.djangoapps.student.models.course_enrollment.CourseEnrollment.is_enrolled', Mock(return_value=True)) def test_pii_params_on_discussion_lti_tab(self, discussion_config_enabled): self.discussion_config.enabled = discussion_config_enabled self.discussion_config.save()