diff --git a/common/djangoapps/student/migrations/0053_auto__add_enrollmentrefundconfiguration.py b/common/djangoapps/student/migrations/0053_auto__add_enrollmentrefundconfiguration.py new file mode 100644 index 0000000000..6311bca541 --- /dev/null +++ b/common/djangoapps/student/migrations/0053_auto__add_enrollmentrefundconfiguration.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'EnrollmentRefundConfiguration' + db.create_table('student_enrollmentrefundconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('refund_window_microseconds', self.gf('django.db.models.fields.BigIntegerField')(default=1209600000000)), + )) + db.send_create_signal('student', ['EnrollmentRefundConfiguration']) + + + def backwards(self, orm): + # Deleting model 'EnrollmentRefundConfiguration' + db.delete_table('student_enrollmentrefundconfiguration') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseaccessrole': { + 'Meta': {'unique_together': "(('user', 'org', 'course_id', 'role'),)", 'object_name': 'CourseAccessRole'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '64', 'blank': 'True'}), + 'role': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.courseenrollmentattribute': { + 'Meta': {'object_name': 'CourseEnrollmentAttribute'}, + 'enrollment': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'attributes'", 'to': "orm['student.CourseEnrollment']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'namespace': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value': ('django.db.models.fields.CharField', [], {'max_length': '255'}) + }, + 'student.dashboardconfiguration': { + 'Meta': {'ordering': "('-change_date',)", 'object_name': 'DashboardConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recent_enrollment_time_delta': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'student.enrollmentrefundconfiguration': { + 'Meta': {'ordering': "('-change_date',)", 'object_name': 'EnrollmentRefundConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'refund_window_microseconds': ('django.db.models.fields.BigIntegerField', [], {'default': '1209600000000'}) + }, + 'student.entranceexamconfiguration': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'EntranceExamConfiguration'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'skip_entrance_exam': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.historicalcourseenrollment': { + 'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalCourseEnrollment'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + u'history_date': ('django.db.models.fields.DateTimeField', [], {}), + u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}), + u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.DO_NOTHING', 'to': "orm['auth.User']"}) + }, + 'student.languageproficiency': { + 'Meta': {'unique_together': "(('code', 'user_profile'),)", 'object_name': 'LanguageProficiency'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '16'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user_profile': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'language_proficiencies'", 'to': "orm['student.UserProfile']"}) + }, + 'student.linkedinaddtoprofileconfiguration': { + 'Meta': {'ordering': "('-change_date',)", 'object_name': 'LinkedInAddToProfileConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'company_identifier': ('django.db.models.fields.TextField', [], {}), + 'dashboard_tracking_code': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'trk_partner_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '10', 'blank': 'True'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.manualenrollmentaudit': { + 'Meta': {'object_name': 'ManualEnrollmentAudit'}, + 'enrolled_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}), + 'enrolled_email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reason': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'state_transition': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'time_stamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'bio': ('django.db.models.fields.CharField', [], {'max_length': '3000', 'null': 'True', 'blank': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'profile_image_uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usersignupsource': { + 'Meta': {'object_name': 'UserSignupSource'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'site': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 8cc1c8a56e..e799bcdf9b 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -49,6 +49,7 @@ from xmodule_django.models import CourseKeyField, NoneToEmptyManager from certificates.models import GeneratedCertificate from course_modes.models import CourseMode import lms.lib.comment_client as cc +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from util.model_utils import emit_field_changed_events, get_changed_fields_dict from util.query import use_read_replica_if_available @@ -1374,7 +1375,10 @@ class CourseEnrollment(models.Model): if GeneratedCertificate.certificate_for_student(self.user, self.course_id) is not None: return False - #TODO - When Course administrators to define a refund period for paid courses then refundable will be supported. # pylint: disable=fixme + # If it is after the refundable cutoff date they should not be refunded. + refund_cutoff_date = self.refund_cutoff_date() + if refund_cutoff_date and datetime.now() > refund_cutoff_date: + return False course_mode = CourseMode.mode_for_course(self.course_id, 'verified') if course_mode is None: @@ -1382,6 +1386,22 @@ class CourseEnrollment(models.Model): else: return True + def refund_cutoff_date(self): + """ Calculate and return the refund window end date. """ + try: + attribute = self.attributes.get(namespace='order', name='order_number') # pylint: disable=no-member + except ObjectDoesNotExist: + return None + + order_number = attribute.value + order = ecommerce_api_client(self.user).orders(order_number).get() + refund_window_start_date = max( + datetime.strptime(order['date_placed'], ECOMMERCE_DATE_FORMAT), + self.course_overview.start.replace(tzinfo=None) + ) + + return refund_window_start_date + EnrollmentRefundConfiguration.current().refund_window + @property def username(self): return self.user.username @@ -2024,3 +2044,34 @@ class CourseEnrollmentAttribute(models.Model): } for attribute in cls.objects.filter(enrollment=enrollment) ] + + +class EnrollmentRefundConfiguration(ConfigurationModel): + """ + Configuration for course enrollment refunds. + """ + + # TODO: Django 1.8 introduces a DurationField + # (https://docs.djangoproject.com/en/1.8/ref/models/fields/#durationfield) + # for storing timedeltas which uses MySQL's bigint for backing + # storage. After we've completed the Django upgrade we should be + # able to replace this field with a DurationField named + # `refund_window` without having to run a migration or change + # other code. + refund_window_microseconds = models.BigIntegerField( + default=1209600000000, + help_text=_( + "The window of time after enrolling during which users can be granted" + " a refund, represented in microseconds. The default is 14 days." + ) + ) + + @property + def refund_window(self): + """Return the configured refund window as a `datetime.timedelta`.""" + return timedelta(microseconds=self.refund_window_microseconds) + + @refund_window.setter + def refund_window(self, refund_window): + """Set the current refund window to the given timedelta.""" + self.refund_window_microseconds = int(refund_window.total_seconds() * 1000000) diff --git a/common/djangoapps/student/tests/test_refunds.py b/common/djangoapps/student/tests/test_refunds.py new file mode 100644 index 0000000000..6c56c9a18a --- /dev/null +++ b/common/djangoapps/student/tests/test_refunds.py @@ -0,0 +1,168 @@ +""" + Tests for enrollment refund capabilities. +""" +from datetime import datetime, timedelta +import ddt +import httpretty +import logging +import pytz +import unittest + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.test.client import Client +from django.test.utils import override_settings +from mock import patch + +from student.models import CourseEnrollment, CourseEnrollmentAttribute +from student.tests.factories import UserFactory, CourseModeFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +# These imports refer to lms djangoapps. +# Their testcases are only run under lms. +from certificates.models import CertificateStatuses # pylint: disable=import-error +from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error +from openedx.core.djangoapps.commerce.utils import ECOMMERCE_DATE_FORMAT + +# Explicitly import the cache from ConfigurationModel so we can reset it after each test +from config_models.models import cache + +log = logging.getLogger(__name__) +TEST_API_URL = 'http://www-internal.example.com/api' +TEST_API_SIGNING_KEY = 'edx' +JSON = 'application/json' + + +@ddt.ddt +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class RefundableTest(SharedModuleStoreTestCase): + """ + Tests for dashboard utility functions + """ + + def setUp(self): + """ Setup components used by each refund test.""" + super(RefundableTest, self).setUp() + self.course = CourseFactory.create() + self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test') + self.verified_mode = CourseModeFactory.create( + course_id=self.course.id, + mode_slug='verified', + mode_display_name='Verified', + expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1) + ) + self.enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='verified') + + self.client = Client() + cache.clear() + + def test_refundable(self): + """ Assert base case is refundable""" + self.assertTrue(self.enrollment.refundable()) + + def test_refundable_expired_verification(self): + """ Assert that enrollment is not refundable if course mode has expired.""" + self.verified_mode.expiration_datetime = datetime.now(pytz.UTC) - timedelta(days=1) + self.verified_mode.save() + self.assertFalse(self.enrollment.refundable()) + + # Assert that can_refund overrides this and allows refund + self.enrollment.can_refund = True + self.assertTrue(self.enrollment.refundable()) + + def test_refundable_of_purchased_course(self): + """ Assert that courses without a verified mode are not refundable""" + self.client.login(username="jack", password="test") + course = CourseFactory.create() + CourseModeFactory.create( + course_id=course.id, + mode_slug='honor', + min_price=10, + currency='usd', + mode_display_name='honor', + expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1) + ) + enrollment = CourseEnrollment.enroll(self.user, course.id, mode='honor') + + # TODO: Until we can allow course administrators to define a refund period for paid for courses show_refund_option should be False. # pylint: disable=fixme + self.assertFalse(enrollment.refundable()) + + resp = self.client.post(reverse('student.views.dashboard', args=[])) + self.assertIn('You will not be refunded the amount you paid.', resp.content) + + def test_refundable_when_certificate_exists(self): + """ Assert that enrollment is not refundable once a certificat has been generated.""" + self.assertTrue(self.enrollment.refundable()) + + GeneratedCertificateFactory.create( + user=self.user, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='verified' + ) + + self.assertFalse(self.enrollment.refundable()) + + # Assert that can_refund overrides this and allows refund + self.enrollment.can_refund = True + self.assertTrue(self.enrollment.refundable()) + + def test_refundable_with_cutoff_date(self): + """ Assert enrollment is refundable before cutoff and not refundable after.""" + self.assertTrue(self.enrollment.refundable()) + + with patch('student.models.CourseEnrollment.refund_cutoff_date') as cutoff_date: + cutoff_date.return_value = datetime.now() - timedelta(days=1) + self.assertFalse(self.enrollment.refundable()) + + cutoff_date.return_value = datetime.now() + timedelta(days=1) + self.assertTrue(self.enrollment.refundable()) + + @ddt.data( + (timedelta(days=1), timedelta(days=2), timedelta(days=2), 14), + (timedelta(days=2), timedelta(days=1), timedelta(days=2), 14), + (timedelta(days=1), timedelta(days=2), timedelta(days=2), 1), + (timedelta(days=2), timedelta(days=1), timedelta(days=2), 1), + ) + @ddt.unpack + @httpretty.activate + @override_settings(ECOMMERCE_API_SIGNING_KEY=TEST_API_SIGNING_KEY, ECOMMERCE_API_URL=TEST_API_URL) + def test_refund_cutoff_date(self, order_date_delta, course_start_delta, expected_date_delta, days): + """ + Assert that the later date is used with the configurable refund period in calculating the returned cutoff date. + """ + now = datetime.now().replace(microsecond=0) + order_date = now + order_date_delta + course_start = now + course_start_delta + expected_date = now + expected_date_delta + refund_period = timedelta(days=days) + order_number = 'OSCR-1000' + expected_content = '{{"date_placed": "{date}"}}'.format(date=order_date.strftime(ECOMMERCE_DATE_FORMAT)) + + httpretty.register_uri( + httpretty.GET, + '{url}/orders/{order}/'.format(url=TEST_API_URL, order=order_number), + status=200, body=expected_content, + adding_headers={'Content-Type': JSON} + ) + + self.enrollment.course_overview.start = course_start + self.enrollment.attributes.add(CourseEnrollmentAttribute( # pylint: disable=no-member + enrollment=self.enrollment, + namespace='order', + name='order_number', + value=order_number + )) + + with patch('student.models.EnrollmentRefundConfiguration.current') as config: + instance = config.return_value + instance.refund_window = refund_period + self.assertEqual( + self.enrollment.refund_cutoff_date(), + expected_date + refund_period + ) + + def test_refund_cutoff_date_no_attributes(self): + """ Assert that the None is returned when no order number attribute is found.""" + self.assertIsNone(self.enrollment.refund_cutoff_date()) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 480138a2c9..13c42268a3 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -3,10 +3,10 @@ Miscellaneous tests for the student app. """ from datetime import datetime, timedelta +import ddt import logging import pytz import unittest -import ddt from django.conf import settings from django.contrib.auth.models import User, AnonymousUser @@ -17,7 +17,8 @@ from mock import Mock, patch from opaque_keys.edx.locations import SlashSeparatedCourseKey from student.models import ( - anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user, LinkedInAddToProfileConfiguration + anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, + unique_id_for_user, LinkedInAddToProfileConfiguration ) from student.views import ( process_survey_link, @@ -287,22 +288,6 @@ class DashboardTest(ModuleStoreTestCase): self.assertFalse(course_mode_info['show_upsell']) self.assertIsNone(course_mode_info['days_for_upsell']) - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - def test_refundable(self): - verified_mode = CourseModeFactory.create( - course_id=self.course.id, - mode_slug='verified', - mode_display_name='Verified', - expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1) - ) - enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='verified') - - self.assertTrue(enrollment.refundable()) - - verified_mode.expiration_datetime = datetime.now(pytz.UTC) - timedelta(days=1) - verified_mode.save() - self.assertFalse(enrollment.refundable()) - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @patch('courseware.views.log.warning') @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) @@ -361,48 +346,6 @@ class DashboardTest(ModuleStoreTestCase): response = self.client.get(reverse('dashboard')) self.assertNotIn('You can no longer access this course because payment has not yet been received', response.content) - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - def test_refundable_of_purchased_course(self): - - self.client.login(username="jack", password="test") - CourseModeFactory.create( - course_id=self.course.id, - mode_slug='honor', - min_price=10, - currency='usd', - mode_display_name='honor', - expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1) - ) - enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='honor') - - # TODO: Until we can allow course administrators to define a refund period for paid for courses show_refund_option should be False. # pylint: disable=fixme - self.assertFalse(enrollment.refundable()) - - resp = self.client.post(reverse('student.views.dashboard', args=[])) - self.assertIn('You will not be refunded the amount you paid.', resp.content) - - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') - def test_refundable_when_certificate_exists(self): - CourseModeFactory.create( - course_id=self.course.id, - mode_slug='verified', - mode_display_name='Verified', - expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1) - ) - - enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode='verified') - - self.assertTrue(enrollment.refundable()) - - GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course.id, - status=CertificateStatuses.downloadable, - mode='verified' - ) - - self.assertFalse(enrollment.refundable()) - @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') def test_linked_in_add_to_profile_btn_not_appearing_without_config(self): # Without linked-in config don't show Add Certificate to LinkedIn button diff --git a/lms/djangoapps/commerce/__init__.py b/lms/djangoapps/commerce/__init__.py index 35e50ffc01..342bfb65c9 100644 --- a/lms/djangoapps/commerce/__init__.py +++ b/lms/djangoapps/commerce/__init__.py @@ -1,40 +1,3 @@ """ Commerce app. """ -from django.conf import settings -from edx_rest_api_client.client import EdxRestApiClient -from eventtracking import tracker - - -def create_tracking_context(user): - """ Assembles attributes from user and request objects to be sent along - in ecommerce api calls for tracking purposes. """ - context_tracker = tracker.get_tracker().resolve_context() - - return { - 'lms_user_id': user.id, - 'lms_client_id': context_tracker.get('client_id'), - 'lms_ip': context_tracker.get('ip'), - } - - -def is_commerce_service_configured(): - """ - Return a Boolean indicating whether or not configuration is present to use - the external commerce service. - """ - return bool(settings.ECOMMERCE_API_URL and settings.ECOMMERCE_API_SIGNING_KEY) - - -def ecommerce_api_client(user): - """ Returns an E-Commerce API client setup with authentication for the specified user. """ - return EdxRestApiClient(settings.ECOMMERCE_API_URL, - settings.ECOMMERCE_API_SIGNING_KEY, - user.username, - user.profile.name, - user.email, - tracking_context=create_tracking_context(user), - issuer=settings.JWT_ISSUER, - expires_in=settings.JWT_EXPIRATION) - - # this is here to support registering the signals in signals.py from commerce import signals # pylint: disable=unused-import diff --git a/lms/djangoapps/commerce/api/v0/views.py b/lms/djangoapps/commerce/api/v0/views.py index b07b73bb15..65feeb27af 100644 --- a/lms/djangoapps/commerce/api/v0/views.py +++ b/lms/djangoapps/commerce/api/v0/views.py @@ -9,7 +9,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT from rest_framework.views import APIView -from commerce import ecommerce_api_client from commerce.constants import Messages from commerce.exceptions import InvalidResponseError from commerce.http import DetailResponse, InternalRequestErrorResponse @@ -19,6 +18,7 @@ from courseware import courses from embargo import api as embargo_api from enrollment.api import add_enrollment from enrollment.views import EnrollmentCrossDomainSessionAuth +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from student.models import CourseEnrollment diff --git a/lms/djangoapps/commerce/api/v1/views.py b/lms/djangoapps/commerce/api/v1/views.py index d57ae1fe2f..3f47631305 100644 --- a/lms/djangoapps/commerce/api/v1/views.py +++ b/lms/djangoapps/commerce/api/v1/views.py @@ -9,11 +9,11 @@ from rest_framework.generics import RetrieveUpdateAPIView, ListAPIView from rest_framework.permissions import IsAuthenticated from rest_framework_oauth.authentication import OAuth2Authentication -from commerce import ecommerce_api_client from commerce.api.v1.models import Course from commerce.api.v1.permissions import ApiKeyOrModelPermission from commerce.api.v1.serializers import CourseSerializer from course_modes.models import CourseMode +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.lib.api.mixins import PutAsCreateMixin from util.json_request import JsonResponse diff --git a/lms/djangoapps/commerce/signals.py b/lms/djangoapps/commerce/signals.py index 437dbc4c3f..85dbf3e1e4 100644 --- a/lms/djangoapps/commerce/signals.py +++ b/lms/djangoapps/commerce/signals.py @@ -15,7 +15,7 @@ import requests from microsite_configuration import microsite from request_cache.middleware import RequestCache from student.models import UNENROLL_DONE -from commerce import ecommerce_api_client, is_commerce_service_configured +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured log = logging.getLogger(__name__) diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py index 000f583c41..fdf63630e8 100644 --- a/lms/djangoapps/commerce/tests/__init__.py +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -12,7 +12,7 @@ import jwt import mock from edx_rest_api_client import auth -from commerce import ecommerce_api_client +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from student.tests.factories import UserFactory JSON = 'application/json' @@ -61,7 +61,7 @@ class EdxRestApiClientTest(TestCase): mock_tracker = mock.Mock() mock_tracker.resolve_context = mock.Mock(return_value={'client_id': self.TEST_CLIENT_ID, 'ip': '127.0.0.1'}) - with mock.patch('commerce.tracker.get_tracker', return_value=mock_tracker): + with mock.patch('openedx.core.djangoapps.commerce.utils.tracker.get_tracker', return_value=mock_tracker): ecommerce_api_client(self.user).baskets(1).post() # make sure the request's JWT token payload included correct tracking context values. diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 1f66dd19de..2710944365 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -29,7 +29,6 @@ from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from commerce import ecommerce_api_client from commerce.utils import audit_log from course_modes.models import CourseMode from courseware.url_helpers import get_redirect_url @@ -37,6 +36,7 @@ from edx_rest_api_client.exceptions import SlumberBaseException from edxmako.shortcuts import render_to_response, render_to_string from embargo import api as embargo_api from microsite_configuration import microsite +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.djangoapps.user_api.accounts import NAME_MIN_LENGTH from openedx.core.djangoapps.user_api.accounts.api import update_account_settings from openedx.core.djangoapps.user_api.errors import UserNotFound, AccountValidationError diff --git a/openedx/core/djangoapps/commerce/__init__.py b/openedx/core/djangoapps/commerce/__init__.py new file mode 100644 index 0000000000..9b06dc24a3 --- /dev/null +++ b/openedx/core/djangoapps/commerce/__init__.py @@ -0,0 +1 @@ +""" Thin Client for the Ecommerce API Service """ diff --git a/openedx/core/djangoapps/commerce/utils.py b/openedx/core/djangoapps/commerce/utils.py new file mode 100644 index 0000000000..1c0fde66e2 --- /dev/null +++ b/openedx/core/djangoapps/commerce/utils.py @@ -0,0 +1,38 @@ +""" Commerce API Service. """ +from django.conf import settings +from edx_rest_api_client.client import EdxRestApiClient +from eventtracking import tracker + +ECOMMERCE_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def create_tracking_context(user): + """ Assembles attributes from user and request objects to be sent along + in ecommerce api calls for tracking purposes. """ + context_tracker = tracker.get_tracker().resolve_context() + + return { + 'lms_user_id': user.id, + 'lms_client_id': context_tracker.get('client_id'), + 'lms_ip': context_tracker.get('ip'), + } + + +def is_commerce_service_configured(): + """ + Return a Boolean indicating whether or not configuration is present to use + the external commerce service. + """ + return bool(settings.ECOMMERCE_API_URL and settings.ECOMMERCE_API_SIGNING_KEY) + + +def ecommerce_api_client(user): + """ Returns an E-Commerce API client setup with authentication for the specified user. """ + return EdxRestApiClient(settings.ECOMMERCE_API_URL, + settings.ECOMMERCE_API_SIGNING_KEY, + user.username, + user.profile.name, + user.email, + tracking_context=create_tracking_context(user), + issuer=settings.JWT_ISSUER, + expires_in=settings.JWT_EXPIRATION)