Add configurable refund window
Add configuration model for enrollment refunds. Use order info from otto in refund window calculation Delete dupe tests. Extend tests to include window tests Move ecom client from lib to djangoapps in openedx
This commit is contained in:
@@ -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']
|
||||
@@ -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)
|
||||
|
||||
168
common/djangoapps/student/tests/test_refunds.py
Normal file
168
common/djangoapps/student/tests/test_refunds.py
Normal file
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
1
openedx/core/djangoapps/commerce/__init__.py
Normal file
1
openedx/core/djangoapps/commerce/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""" Thin Client for the Ecommerce API Service """
|
||||
38
openedx/core/djangoapps/commerce/utils.py
Normal file
38
openedx/core/djangoapps/commerce/utils.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user