Merge pull request #10384 from edx/bderusha/refund-window
Refund windows
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