diff --git a/AUTHORS b/AUTHORS index 3510bcd3ab55a12d5c631ebd0df2ef5b55c17c9a..1db151f2407b22f6929f611d880b19e36c5ce1ad 100644 --- a/AUTHORS +++ b/AUTHORS @@ -158,3 +158,5 @@ Tim Babych <tim.babych@gmail.com> Brandon DeRosier <btd@cheesekeg.com> Daniel Li <swli@edx.org> Daniel Friedman <dfriedman@edx.org> +Asad Iqbal <aiqbal@edx.org> +Muhammad Shoaib <mshoaib@edx.org> diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 209f7cf6c0a31656054042ae20ffdbdc200dbba1..e64caedd4f54d0ce82b5ff2228ddc4bf4587837b 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -3,7 +3,7 @@ django admin pages for courseware model ''' from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed -from student.models import CourseEnrollment, Registration, PendingNameChange +from student.models import CourseEnrollment, Registration, PendingNameChange, CourseAccessRole from ratelimitbackend import admin admin.site.register(UserProfile) @@ -17,3 +17,5 @@ admin.site.register(CourseEnrollmentAllowed) admin.site.register(Registration) admin.site.register(PendingNameChange) + +admin.site.register(CourseAccessRole) diff --git a/common/djangoapps/student/migrations/0037_auto__add_courseregistrationcode.py b/common/djangoapps/student/migrations/0037_auto__add_courseregistrationcode.py new file mode 100644 index 0000000000000000000000000000000000000000..5853fcdd34bb28c3b1569982cae57cbe513d7158 --- /dev/null +++ b/common/djangoapps/student/migrations/0037_auto__add_courseregistrationcode.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +import 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 'CourseRegistrationCode' + db.create_table('student_courseregistrationcode', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('code', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('transaction_group_name', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, null=True, blank=True)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='created_by_user', to=orm['auth.User'])), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 6, 24, 0, 0))), + ('redeemed_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='redeemed_by_user', null=True, to=orm['auth.User'])), + ('redeemed_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 6, 24, 0, 0), null=True)), + )) + db.send_create_signal('student', ['CourseRegistrationCode']) + + + def backwards(self, orm): + # Deleting model 'CourseRegistrationCode' + db.delete_table('student_courseregistrationcode') + + + 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.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 6, 24, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 6, 24, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'redeemed_by_user'", 'null': 'True', 'to': "orm['auth.User']"}), + 'transaction_group_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', '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.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'}), + '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'}), + '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.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/migrations/0038_auto__add_usersignupsource.py b/common/djangoapps/student/migrations/0038_auto__add_usersignupsource.py new file mode 100644 index 0000000000000000000000000000000000000000..fd6c7a883c0bb5669cf003e98edec39024baa920 --- /dev/null +++ b/common/djangoapps/student/migrations/0038_auto__add_usersignupsource.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +import 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 'UserSignupSource' + db.create_table('student_usersignupsource', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('site', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + )) + db.send_create_signal('student', ['UserSignupSource']) + + + def backwards(self, orm): + # Deleting model 'UserSignupSource' + db.delete_table('student_usersignupsource') + + + 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.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 6, 25, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 6, 25, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'redeemed_by_user'", 'null': 'True', 'to': "orm['auth.User']"}), + 'transaction_group_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', '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.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'}), + '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'}), + '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_id': ('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/migrations/0039_auto__del_courseregistrationcode.py b/common/djangoapps/student/migrations/0039_auto__del_courseregistrationcode.py new file mode 100644 index 0000000000000000000000000000000000000000..4960e2cbc4d9a1c0a74cb37734fb9ca9d16857ff --- /dev/null +++ b/common/djangoapps/student/migrations/0039_auto__del_courseregistrationcode.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Deleting model 'CourseRegistrationCode' + db.delete_table('student_courseregistrationcode') + + + def backwards(self, orm): + # Adding model 'CourseRegistrationCode' + db.create_table('student_courseregistrationcode', ( + ('code', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('transaction_group_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=255, null=True, db_index=True)), + ('redeemed_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='redeemed_by_user', null=True, to=orm['auth.User'])), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('redeemed_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 6, 25, 0, 0), null=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 6, 25, 0, 0))), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='created_by_user', to=orm['auth.User'])), + )) + db.send_create_signal('student', ['CourseRegistrationCode']) + + + 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.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.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'}), + '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'}), + '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_id': ('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 b487088b8c7a9ebd08f67d0bac2c45b5e30bfdf9..40d4ac0d6c41ef2586095cdbea337277e520ad8e 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -271,6 +271,15 @@ class UserProfile(models.Model): self.save() +class UserSignupSource(models.Model): + """ + This table contains information about users registering + via Micro-Sites + """ + user_id = models.ForeignKey(User, db_index=True) + site = models.CharField(max_length=255, db_index=True) + + def unique_id_for_user(user, save=True): """ Return a unique id for a user, suitable for inserting into @@ -1035,6 +1044,9 @@ class CourseAccessRole(models.Model): """ return self._key < other._key + def __unicode__(self): + return "[CourseAccessRole] user: {} role: {} org: {} course: {}".format(self.user.username, self.role, self.org, self.course_id) + #### Helper methods for use from python manage.py shell and other classes. diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 640b7cd7033b8a69cb524e93406c7d172175196b..6061ce1ea2deac7a9fc3d84e69072255b0ee750e 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -201,6 +201,13 @@ class CourseInstructorRole(CourseRole): super(CourseInstructorRole, self).__init__(self.ROLE, *args, **kwargs) +class CourseFinanceAdminRole(CourseRole): + """A course Instructor""" + ROLE = 'finance_admin' + + def __init__(self, *args, **kwargs): + super(CourseFinanceAdminRole, self).__init__(self.ROLE, *args, **kwargs) + class CourseBetaTesterRole(CourseRole): """A course Beta Tester""" ROLE = 'beta_testers' diff --git a/common/djangoapps/student/tests/test_microsite.py b/common/djangoapps/student/tests/test_microsite.py new file mode 100644 index 0000000000000000000000000000000000000000..b447ad3a13c10e2b8c2d08eb2ea18f2141b07bb1 --- /dev/null +++ b/common/djangoapps/student/tests/test_microsite.py @@ -0,0 +1,51 @@ +""" +Test for User Creation from Micro-Sites +""" +from django.test import TestCase +from student.models import UserSignupSource +import mock +from django.core.urlresolvers import reverse + + +def fake_site_name(name, default=None): # pylint: disable=W0613 + """ + create a fake microsite site name + """ + if name == 'SITE_NAME': + return 'openedx.localhost' + else: + return None + + +class TestMicrosite(TestCase): + """Test for Account Creation from a white labeled Micro-Sites""" + def setUp(self): + self.username = "test_user" + self.url = reverse("create_account") + self.params = { + "username": self.username, + "email": "test@example.org", + "password": "testpass", + "name": "Test User", + "honor_code": "true", + "terms_of_service": "true", + } + + @mock.patch("microsite_configuration.microsite.get_value", fake_site_name) + def test_user_signup_source(self): + """ + test to create a user form the microsite and see that it record has been + saved in the UserSignupSource Table + """ + response = self.client.post(self.url, self.params) + self.assertEqual(response.status_code, 200) + self.assertGreater(len(UserSignupSource.objects.filter(site='openedx.localhost')), 0) + + def test_user_signup_from_non_micro_site(self): + """ + test to create a user form the non-microsite. The record should not be saved + in the UserSignupSource Table + """ + response = self.client.post(self.url, self.params) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(UserSignupSource.objects.filter(site='openedx.localhost')), 0) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index cd69ebc046da4404ed1634559acb0394c5e3e9a2..0f408ab0dd84bba472b43fea1014b90e0a18253e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -30,6 +30,9 @@ from django.utils.translation import ugettext as _, get_language from django.views.decorators.cache import never_cache from django.views.decorators.http import require_POST, require_GET +from django.db.models.signals import post_save +from django.dispatch import receiver + from django.template.response import TemplateResponse from ratelimitbackend.exceptions import RateLimitException @@ -42,7 +45,7 @@ from student.models import ( Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, CourseEnrollmentAllowed, UserStanding, LoginFailures, - create_comments_service_user, PasswordHistory + create_comments_service_user, PasswordHistory, UserSignupSource ) from student.forms import PasswordResetFormNoActive @@ -1021,6 +1024,21 @@ class AccountValidationError(Exception): super(AccountValidationError, self).__init__(message) self.field = field + +@receiver(post_save, sender=User) +def user_signup_handler(sender, **kwargs): # pylint: disable=W0613 + """ + handler that saves the user Signup Source + when the user is created + """ + if 'created' in kwargs and kwargs['created']: + site = microsite.get_value('SITE_NAME') + if site: + user_signup_source = UserSignupSource(user_id=kwargs['instance'], site=site) + user_signup_source.save() + log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id)) + + def _do_create_account(post_vars): """ Given cleaned post variables, create the User and UserProfile objects, as well as the diff --git a/lms/djangoapps/instructor/tests/test_ecommerce.py b/lms/djangoapps/instructor/tests/test_ecommerce.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb4b1435a45a79c9c9990118dad65bbd0504885 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_ecommerce.py @@ -0,0 +1,193 @@ +""" +Unit tests for Ecommerce feature flag in new instructor dashboard. +""" + +from django.test.utils import override_settings +from django.core.urlresolvers import reverse + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from student.tests.factories import AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from course_modes.models import CourseMode +from shoppingcart.models import Coupon, PaidCourseRegistration +from mock import patch +from student.roles import CourseFinanceAdminRole + + +# pylint: disable=E1101 +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestECommerceDashboardViews(ModuleStoreTestCase): + """ + Check for email view on the new instructor dashboard + for Mongo-backed courses + """ + def setUp(self): + self.course = CourseFactory.create() + + # Create instructor account + self.instructor = AdminFactory.create() + self.client.login(username=self.instructor.username, password="test") + mode = CourseMode( + course_id=self.course.id.to_deprecated_string(), mode_slug='honor', + mode_display_name='honor', min_price=10, currency='usd' + ) + mode.save() + # URL for instructor dash + self.url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()}) + self.e_commerce_link = '<a href="" data-section="e-commerce">E-Commerce</a>' + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + + def tearDown(self): + """ + Undo all patches. + """ + patch.stopall() + + def test_pass_e_commerce_tab_in_instructor_dashboard(self): + """ + Test Pass E-commerce Tab is in the Instructor Dashboard + """ + response = self.client.get(self.url) + self.assertTrue(self.e_commerce_link in response.content) + + def test_user_has_finance_admin_rights_in_e_commerce_tab(self): + response = self.client.get(self.url) + self.assertTrue(self.e_commerce_link in response.content) + + # Total amount html should render in e-commerce page, total amount will be 0 + total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id) + self.assertTrue('<span>Total Amount: <span>$' + str(total_amount) + '</span></span>' in response.content) + + # removing the course finance_admin role of login user + CourseFinanceAdminRole(self.course.id).remove_users(self.instructor) + + # total amount should not be visible in e-commerce page if the user is not finance admin + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(url) + total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course.id) + self.assertFalse('<span>Total Amount: <span>$' + str(total_amount) + '</span></span>' in response.content) + + def test_add_coupon(self): + """ + Test Add Coupon Scenarios. Handle all the HttpResponses return by add_coupon view + """ + # URL for add_coupon + add_coupon_url = reverse('add_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()}) + data = { + 'code': 'A2314', 'course_id': self.course.id.to_deprecated_string(), + 'description': 'ADSADASDSAD', 'created_by': self.instructor, 'discount': 5 + } + response = self.client.post(add_coupon_url, data) + self.assertTrue("coupon with the coupon code ({code}) added successfully".format(code=data['code']) in response.content) + + data = { + 'code': 'A2314', 'course_id': self.course.id.to_deprecated_string(), + 'description': 'asdsasda', 'created_by': self.instructor, 'discount': 111 + } + response = self.client.post(add_coupon_url, data) + self.assertTrue("coupon with the coupon code ({code}) already exist".format(code='A2314') in response.content) + + response = self.client.post(self.url) + self.assertTrue('<td>ADSADASDSAD</td>' in response.content) + self.assertTrue('<td>A2314</td>' in response.content) + self.assertFalse('<td>111</td>' in response.content) + + def test_delete_coupon(self): + """ + Test Delete Coupon Scenarios. Handle all the HttpResponses return by remove_coupon view + """ + coupon = Coupon( + code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(), + percentage_discount=10, created_by=self.instructor + ) + + coupon.save() + + response = self.client.post(self.url) + self.assertTrue('<td>AS452</td>' in response.content) + + # URL for remove_coupon + delete_coupon_url = reverse('remove_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(delete_coupon_url, {'id': coupon.id}) + self.assertTrue('coupon with the coupon id ({coupon_id}) updated successfully'.format(coupon_id=coupon.id) in response.content) + + coupon.is_active = False + coupon.save() + + response = self.client.post(delete_coupon_url, {'id': coupon.id}) + self.assertTrue('coupon with the coupon id ({coupon_id}) is already inactive'.format(coupon_id=coupon.id) in response.content) + + response = self.client.post(delete_coupon_url, {'id': 24454}) + self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=24454) in response.content) + + response = self.client.post(delete_coupon_url, {'id': ''}) + self.assertTrue('coupon id is None' in response.content) + + def test_get_coupon_info(self): + """ + Test Edit Coupon Info Scenarios. Handle all the HttpResponses return by edit_coupon_info view + """ + coupon = Coupon( + code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(), + percentage_discount=10, created_by=self.instructor + ) + coupon.save() + # URL for edit_coupon_info + edit_url = reverse('get_coupon_info', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(edit_url, {'id': coupon.id}) + self.assertTrue('coupon with the coupon id ({coupon_id}) updated successfully'.format(coupon_id=coupon.id) in response.content) + + response = self.client.post(edit_url, {'id': 444444}) + self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=444444) in response.content) + + response = self.client.post(edit_url, {'id': ''}) + self.assertTrue('coupon id not found"' in response.content) + + coupon.is_active = False + coupon.save() + + response = self.client.post(edit_url, {'id': coupon.id}) + self.assertTrue("coupon with the coupon id ({coupon_id}) is already inactive".format(coupon_id=coupon.id) in response.content) + + def test_update_coupon(self): + """ + Test Update Coupon Info Scenarios. Handle all the HttpResponses return by update_coupon view + """ + coupon = Coupon( + code='AS452', description='asdsadsa', course_id=self.course.id.to_deprecated_string(), + percentage_discount=10, created_by=self.instructor + ) + coupon.save() + response = self.client.post(self.url) + self.assertTrue('<td>AS452</td>' in response.content) + data = { + 'coupon_id': coupon.id, 'code': 'update_code', 'discount': '12', + 'course_id': coupon.course_id.to_deprecated_string() + } + # URL for update_coupon + update_coupon_url = reverse('update_coupon', kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(update_coupon_url, data=data) + self.assertTrue('coupon with the coupon id ({coupon_id}) updated Successfully'.format(coupon_id=coupon.id)in response.content) + + response = self.client.post(self.url) + self.assertTrue('<td>update_code</td>' in response.content) + self.assertTrue('<td>12</td>' in response.content) + + data['coupon_id'] = 1000 # Coupon Not Exist with this ID + response = self.client.post(update_coupon_url, data=data) + self.assertTrue('coupon with the coupon id ({coupon_id}) DoesNotExist'.format(coupon_id=1000) in response.content) + + data['coupon_id'] = '' # Coupon id is not provided + response = self.client.post(update_coupon_url, data=data) + self.assertTrue('coupon id not found' in response.content) + + coupon1 = Coupon( + code='11111', description='coupon', course_id=self.course.id.to_deprecated_string(), + percentage_discount=20, created_by=self.instructor + ) + coupon1.save() + data = {'coupon_id': coupon.id, 'code': '11111', 'discount': '12'} + response = self.client.post(update_coupon_url, data=data) + self.assertTrue('coupon with the coupon id ({coupon_id}) already exist'.format(coupon_id=coupon.id) in response.content) diff --git a/lms/djangoapps/instructor/views/coupons.py b/lms/djangoapps/instructor/views/coupons.py new file mode 100644 index 0000000000000000000000000000000000000000..7251e470c5b5f7bbd1ae425d1fe3dd70ab13d994 --- /dev/null +++ b/lms/djangoapps/instructor/views/coupons.py @@ -0,0 +1,135 @@ +""" +E-commerce Tab Instructor Dashboard Coupons Operations views +""" +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q +from django.views.decorators.http import require_POST +from django.utils.translation import ugettext as _ +from util.json_request import JsonResponse +from django.http import HttpResponse, HttpResponseNotFound +from shoppingcart.models import Coupon + +import logging + +log = logging.getLogger(__name__) + + +@require_POST +@login_required +def remove_coupon(request, course_id): # pylint: disable=W0613 + """ + remove the coupon against the coupon id + set the coupon is_active flag to false + """ + coupon_id = request.POST.get('id', None) + if not coupon_id: + return JsonResponse({ + 'message': _('coupon id is None') + }, status=400) # status code 400: Bad Request + + try: + coupon = Coupon.objects.get(id=coupon_id) + except ObjectDoesNotExist: + return JsonResponse({ + 'message': _('coupon with the coupon id ({coupon_id}) DoesNotExist').format(coupon_id=coupon_id) + }, status=400) # status code 400: Bad Request + if not coupon.is_active: + return JsonResponse({ + 'message': _('coupon with the coupon id ({coupon_id}) is already inactive').format(coupon_id=coupon_id) + }, status=400) # status code 400: Bad Request + coupon.is_active = False + coupon.save() + return JsonResponse({ + 'message': _('coupon with the coupon id ({coupon_id}) updated successfully').format(coupon_id=coupon_id) + }) # status code 200: OK by default + + +@require_POST +@login_required +def add_coupon(request, course_id): # pylint: disable=W0613 + """ + add coupon in the Coupons Table + """ + code = request.POST.get('code') + + # check if the code is already in the Coupons Table and active + coupon = Coupon.objects.filter(is_active=True, code=code) + + if coupon: + return HttpResponseNotFound(_("coupon with the coupon code ({code}) already exist").format(code=code)) + + description = request.POST.get('description') + course_id = request.POST.get('course_id') + discount = request.POST.get('discount') + coupon = Coupon( + code=code, description=description, course_id=course_id, + percentage_discount=discount, created_by_id=request.user.id + ) + coupon.save() + return HttpResponse(_("coupon with the coupon code ({code}) added successfully").format(code=code)) + + +@require_POST +@login_required +def update_coupon(request, course_id): # pylint: disable=W0613 + """ + update the coupon object in the database + """ + coupon_id = request.POST.get('coupon_id', None) + if not coupon_id: + return HttpResponseNotFound(_("coupon id not found")) + + try: + coupon = Coupon.objects.get(pk=coupon_id) + except ObjectDoesNotExist: + return HttpResponseNotFound(_("coupon with the coupon id ({coupon_id}) DoesNotExist").format(coupon_id=coupon_id)) + + code = request.POST.get('code') + filtered_coupons = Coupon.objects.filter(~Q(id=coupon_id), code=code, is_active=True) + + if filtered_coupons: + return HttpResponseNotFound(_("coupon with the coupon id ({coupon_id}) already exists").format(coupon_id=coupon_id)) + + description = request.POST.get('description') + course_id = request.POST.get('course_id') + discount = request.POST.get('discount') + coupon.code = code + coupon.description = description + coupon.course_id = course_id + coupon.percentage_discount = discount + coupon.save() + return HttpResponse(_("coupon with the coupon id ({coupon_id}) updated Successfully").format(coupon_id=coupon_id)) + + +@require_POST +@login_required +def get_coupon_info(request, course_id): # pylint: disable=W0613 + """ + get the coupon information to display in the pop up form + """ + coupon_id = request.POST.get('id', None) + if not coupon_id: + return JsonResponse({ + 'message': _("coupon id not found") + }, status=400) # status code 400: Bad Request + + try: + coupon = Coupon.objects.get(id=coupon_id) + except ObjectDoesNotExist: + return JsonResponse({ + 'message': _("coupon with the coupon id ({coupon_id}) DoesNotExist").format(coupon_id=coupon_id) + }, status=400) # status code 400: Bad Request + + if not coupon.is_active: + return JsonResponse({ + 'message': _("coupon with the coupon id ({coupon_id}) is already inactive").format(coupon_id=coupon_id) + }, status=400) # status code 400: Bad Request + + return JsonResponse({ + 'coupon_code': coupon.code, + 'coupon_description': coupon.description, + 'coupon_course_id': coupon.course_id.to_deprecated_string(), + 'coupon_discount': coupon.percentage_discount, + 'message': _('coupon with the coupon id ({coupon_id}) updated successfully').format(coupon_id=coupon_id) + }) # status code 200: OK by default diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index abebd0fb116437ace335d81d74f2cac19b539504..f2e7fba5e1cc9723ac1be2ed84a2b61dcdfafe31 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -26,6 +26,10 @@ from courseware.courses import get_course_by_id, get_cms_course_link, get_course from django_comment_client.utils import has_forum_access from django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from student.models import CourseEnrollment +from shoppingcart.models import Coupon, PaidCourseRegistration +from course_modes.models import CourseMode +from student.roles import CourseFinanceAdminRole + from bulk_email.models import CourseAuthorization from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem @@ -49,6 +53,7 @@ def instructor_dashboard_2(request, course_id): access = { 'admin': request.user.is_staff, 'instructor': has_access(request.user, 'instructor', course), + 'finance_admin': CourseFinanceAdminRole(course_key).has_user(request.user), 'staff': has_access(request.user, 'staff', course), 'forum_admin': has_forum_access( request.user, course_key, FORUM_ROLE_ADMINISTRATOR @@ -66,6 +71,12 @@ def instructor_dashboard_2(request, course_id): _section_analytics(course_key, access), ] + #check if there is corresponding entry in the CourseMode Table related to the Instructor Dashboard course + course_honor_mode = CourseMode.mode_for_course(course_key, 'honor') + course_mode_has_price = False + if course_honor_mode and course_honor_mode.min_price > 0: + course_mode_has_price = True + if (settings.FEATURES.get('INDIVIDUAL_DUE_DATES') and access['instructor']): sections.insert(3, _section_extensions(course)) @@ -77,6 +88,11 @@ def instructor_dashboard_2(request, course_id): if settings.FEATURES['CLASS_DASHBOARD'] and access['staff']: sections.append(_section_metrics(course_key, access)) + # Gate access to Ecommerce tab + if course_mode_has_price: + sections.append(_section_e_commerce(course_key, access)) + + studio_url = None if is_studio_course: studio_url = get_cms_course_link(course) @@ -111,6 +127,29 @@ section_display_name will be used to generate link titles in the nav bar. """ # pylint: disable=W0105 +def _section_e_commerce(course_key, access): + """ Provide data for the corresponding dashboard section """ + coupons = Coupon.objects.filter(course_id=course_key).order_by('-is_active') + total_amount = None + if access['finance_admin']: + total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(course_key) + + section_data = { + 'section_key': 'e-commerce', + 'section_display_name': _('E-Commerce'), + 'access': access, + 'course_id': course_key.to_deprecated_string(), + 'ajax_remove_coupon_url': reverse('remove_coupon', kwargs={'course_id': course_key.to_deprecated_string()}), + 'ajax_get_coupon_info': reverse('get_coupon_info', kwargs={'course_id': course_key.to_deprecated_string()}), + 'ajax_update_coupon': reverse('update_coupon', kwargs={'course_id': course_key.to_deprecated_string()}), + 'ajax_add_coupon': reverse('add_coupon', kwargs={'course_id': course_key.to_deprecated_string()}), + 'instructor_url': reverse('instructor_dashboard', kwargs={'course_id': course_key.to_deprecated_string()}), + 'coupons': coupons, + 'total_amount': total_amount, + } + return section_data + + def _section_course_info(course_key, access): """ Provide data for the corresponding dashboard section """ course = get_course_by_id(course_key, depth=None) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index b6f826040bfeb9865c3ca3e98cb3feb1d7a7ad46..8f4b30c583ab2d42b1ec1559dbca0e129b08a3a2 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -28,6 +28,18 @@ class CourseDoesNotExistException(InvalidCartItem): pass +class CouponDoesNotExistException(InvalidCartItem): + pass + + +class CouponAlreadyExistException(InvalidCartItem): + pass + + +class ItemDoesNotExistAgainstCouponException(InvalidCartItem): + pass + + class ReportException(Exception): pass diff --git a/lms/djangoapps/shoppingcart/migrations/0008_auto__add_coupons__add_couponredemption__chg_field_certificateitem_cou.py b/lms/djangoapps/shoppingcart/migrations/0008_auto__add_coupons__add_couponredemption__chg_field_certificateitem_cou.py new file mode 100644 index 0000000000000000000000000000000000000000..8ff9a0d7ae5eaae87f021d0c6246e1a958605ac2 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0008_auto__add_coupons__add_couponredemption__chg_field_certificateitem_cou.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +import 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 'Coupons' + db.create_table('shoppingcart_coupons', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('code', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('description', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)), + ('percentage_discount', self.gf('django.db.models.fields.IntegerField')(default=0)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 6, 24, 0, 0))), + ('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)), + )) + db.send_create_signal('shoppingcart', ['Coupons']) + + # Adding model 'CouponRedemption' + db.create_table('shoppingcart_couponredemption', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('order', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Order'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('coupon', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Coupons'])), + )) + db.send_create_signal('shoppingcart', ['CouponRedemption']) + + + # Changing field 'CertificateItem.course_id' + db.alter_column('shoppingcart_certificateitem', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=128)) + + # Changing field 'PaidCourseRegistrationAnnotation.course_id' + db.alter_column('shoppingcart_paidcourseregistrationannotation', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(unique=True, max_length=128)) + + # Changing field 'PaidCourseRegistration.course_id' + db.alter_column('shoppingcart_paidcourseregistration', 'course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=128)) + # Adding field 'OrderItem.discount_price' + db.add_column('shoppingcart_orderitem', 'discount_price', + self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=30, decimal_places=2), + keep_default=False) + + + def backwards(self, orm): + # Deleting model 'Coupons' + db.delete_table('shoppingcart_coupons') + + # Deleting model 'CouponRedemption' + db.delete_table('shoppingcart_couponredemption') + + + # Changing field 'CertificateItem.course_id' + db.alter_column('shoppingcart_certificateitem', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=128)) + + # Changing field 'PaidCourseRegistrationAnnotation.course_id' + db.alter_column('shoppingcart_paidcourseregistrationannotation', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=128, unique=True)) + + # Changing field 'PaidCourseRegistration.course_id' + db.alter_column('shoppingcart_paidcourseregistration', 'course_id', self.gf('django.db.models.fields.CharField')(max_length=128)) + # Deleting field 'OrderItem.discount_price' + db.delete_column('shoppingcart_orderitem', 'discount_price') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.couponredemption': { + 'Meta': {'object_name': 'CouponRedemption'}, + 'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupons']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.coupons': { + 'Meta': {'object_name': 'Coupons'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 6, 24, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'discount_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + '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']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/0009_auto__del_coupons__add_courseregistrationcode__add_coupon__chg_field_c.py b/lms/djangoapps/shoppingcart/migrations/0009_auto__del_coupons__add_courseregistrationcode__add_coupon__chg_field_c.py new file mode 100644 index 0000000000000000000000000000000000000000..3850c104999c2bcf130dfa677c831b6dee385f89 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0009_auto__del_coupons__add_courseregistrationcode__add_coupon__chg_field_c.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Deleting model 'Coupons' + db.delete_table('shoppingcart_coupons') + + # Adding model 'CourseRegistrationCode' + db.create_table('shoppingcart_courseregistrationcode', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('code', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('transaction_group_name', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=255, null=True, blank=True)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='created_by_user', to=orm['auth.User'])), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 7, 1, 0, 0))), + ('redeemed_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='redeemed_by_user', null=True, to=orm['auth.User'])), + ('redeemed_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 7, 1, 0, 0), null=True)), + )) + db.send_create_signal('shoppingcart', ['CourseRegistrationCode']) + + # Adding model 'Coupon' + db.create_table('shoppingcart_coupon', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('code', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('description', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)), + ('percentage_discount', self.gf('django.db.models.fields.IntegerField')(default=0)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 7, 1, 0, 0))), + ('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)), + )) + db.send_create_signal('shoppingcart', ['Coupon']) + + + # Changing field 'CouponRedemption.coupon' + db.alter_column('shoppingcart_couponredemption', 'coupon_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Coupon'])) + # Deleting field 'OrderItem.discount_price' + db.delete_column('shoppingcart_orderitem', 'discount_price') + + # Adding field 'OrderItem.list_price' + db.add_column('shoppingcart_orderitem', 'list_price', + self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=30, decimal_places=2), + keep_default=False) + + + def backwards(self, orm): + # Adding model 'Coupons' + db.create_table('shoppingcart_coupons', ( + ('code', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('percentage_discount', self.gf('django.db.models.fields.IntegerField')(default=0)), + ('description', self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime(2014, 6, 24, 0, 0))), + ('is_active', self.gf('django.db.models.fields.BooleanField')(default=True)), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + )) + db.send_create_signal('shoppingcart', ['Coupons']) + + # Deleting model 'CourseRegistrationCode' + db.delete_table('shoppingcart_courseregistrationcode') + + # Deleting model 'Coupon' + db.delete_table('shoppingcart_coupon') + + + # Changing field 'CouponRedemption.coupon' + db.alter_column('shoppingcart_couponredemption', 'coupon_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Coupons'])) + # Adding field 'OrderItem.discount_price' + db.add_column('shoppingcart_orderitem', 'discount_price', + self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=30, decimal_places=2), + keep_default=False) + + # Deleting field 'OrderItem.list_price' + db.delete_column('shoppingcart_orderitem', 'list_price') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.coupon': { + 'Meta': {'object_name': 'Coupon'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 7, 1, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'percentage_discount': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'shoppingcart.couponredemption': { + 'Meta': {'object_name': 'CouponRedemption'}, + 'coupon': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Coupon']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.courseregistrationcode': { + 'Meta': {'object_name': 'CourseRegistrationCode'}, + 'code': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 7, 1, 0, 0)'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_by_user'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'redeemed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2014, 7, 1, 0, 0)', 'null': 'True'}), + 'redeemed_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'redeemed_by_user'", 'null': 'True', 'to': "orm['auth.User']"}), + 'transaction_group_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'list_price': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '30', 'decimal_places': '2'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'service_fee': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32', 'db_index': 'True'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + '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']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 8e54a977b621f6ae0531113498a252d98cf54935..406257fd016f118dd3af3cfc128a5ef990d419f7 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -31,7 +31,7 @@ from xmodule_django.models import CourseKeyField from verify_student.models import SoftwareSecurePhotoVerification from .exceptions import (InvalidCartItem, PurchasedCallbackException, ItemAlreadyInCartException, - AlreadyEnrolledInCourseException, CourseDoesNotExistException) + AlreadyEnrolledInCourseException, CourseDoesNotExistException, CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException) from microsite_configuration import microsite @@ -217,6 +217,7 @@ class OrderItem(models.Model): status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES, db_index=True) qty = models.IntegerField(default=1) unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) + list_price = models.DecimalField(decimal_places=2, max_digits=30, null=True) line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes fulfilled_time = models.DateTimeField(null=True, db_index=True) @@ -304,6 +305,78 @@ class OrderItem(models.Model): return '' +class CourseRegistrationCode(models.Model): + """ + This table contains registration codes + With registration code, a user can register for a course for free + """ + code = models.CharField(max_length=32, db_index=True) + course_id = CourseKeyField(max_length=255, db_index=True) + transaction_group_name = models.CharField(max_length=255, db_index=True, null=True, blank=True) + created_by = models.ForeignKey(User, related_name='created_by_user') + created_at = models.DateTimeField(default=datetime.now(pytz.utc)) + redeemed_by = models.ForeignKey(User, null=True, related_name='redeemed_by_user') + redeemed_at = models.DateTimeField(default=datetime.now(pytz.utc), null=True) + + +class Coupon(models.Model): + """ + This table contains coupon codes + A user can get a discount offer on course if provide coupon code + """ + code = models.CharField(max_length=32, db_index=True) + description = models.CharField(max_length=255, null=True, blank=True) + course_id = CourseKeyField(max_length=255) + percentage_discount = models.IntegerField(default=0) + created_by = models.ForeignKey(User) + created_at = models.DateTimeField(default=datetime.now(pytz.utc)) + is_active = models.BooleanField(default=True) + + +class CouponRedemption(models.Model): + """ + This table contain coupon redemption info + """ + order = models.ForeignKey(Order, db_index=True) + user = models.ForeignKey(User, db_index=True) + coupon = models.ForeignKey(Coupon, db_index=True) + + @classmethod + def get_discount_price(cls, percentage_discount, value): + """ + return discounted price against coupon + """ + discount = Decimal("{0:.2f}".format(Decimal(percentage_discount / 100.00) * value)) + return value - discount + + @classmethod + def add_coupon_redemption(cls, coupon, order): + """ + add coupon info into coupon_redemption model + """ + cart_items = order.orderitem_set.all().select_subclasses() + + for item in cart_items: + if getattr(item, 'course_id'): + if item.course_id == coupon.course_id: + coupon_redemption, created = cls.objects.get_or_create(order=order, user=order.user, coupon=coupon) + if not created: + log.exception("Coupon '{0}' already exist for user '{1}' against order id '{2}'" + .format(coupon.code, order.user.username, order.id)) + raise CouponAlreadyExistException + + discount_price = cls.get_discount_price(coupon.percentage_discount, item.unit_cost) + item.list_price = item.unit_cost + item.unit_cost = discount_price + item.save() + log.info("Discount generated for user {0} against order id '{1}' " + .format(order.user.username, order.id)) + return coupon_redemption + + log.warning("Course item does not exist for coupon '{0}'".format(coupon.code)) + raise ItemDoesNotExistAgainstCouponException + + class PaidCourseRegistration(OrderItem): """ This is an inventory item for paying for a course registration @@ -319,6 +392,19 @@ class PaidCourseRegistration(OrderItem): return course_id in [item.paidcourseregistration.course_id for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] + @classmethod + def get_total_amount_of_purchased_item(cls, course_key): + """ + This will return the total amount of money that a purchased course generated + """ + total_cost = 0 + result = cls.objects.filter(course_id=course_key, status='purchased').aggregate(total=Sum('unit_cost', field='qty * unit_cost')) # pylint: disable=E1101 + + if result['total'] is not None: + total_cost = result['total'] + + return total_cost + @classmethod @transaction.commit_on_success def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 5c980073d71f58f960f5ea19769148507a865418..b2ad8ccd660a0a2e48fb161b0e8db34770e902a0 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -16,8 +16,25 @@ from django.utils.translation import ugettext as _ from edxmako.shortcuts import render_to_string from shoppingcart.models import Order from shoppingcart.processors.exceptions import * +from microsite_configuration import microsite +def get_cybersource_config(): + """ + This method will return any microsite specific cybersource configuration, otherwise + we return the default configuration + """ + config_key = microsite.get_value('cybersource_config_key') + config = {} + if config_key: + # The microsite CyberSource configuration will be subkeys inside of the normal default + # CyberSource configuration + config = settings.CC_PROCESSOR['CyberSource']['microsites'][config_key] + else: + config = settings.CC_PROCESSOR['CyberSource'] + + return config + def process_postpay_callback(params): """ The top level call to this module, basically @@ -53,7 +70,7 @@ def processor_hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page """ - shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET', '') + shared_secret = get_cybersource_config().get('SHARED_SECRET', '') hash_obj = hmac.new(shared_secret.encode('utf-8'), value.encode('utf-8'), sha1) return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want @@ -63,9 +80,9 @@ def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='order params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID', '') - order_page_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION', '7') - serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER', '') + merchant_id = get_cybersource_config().get('MERCHANT_ID', '') + order_page_version = get_cybersource_config().get('ORDERPAGE_VERSION', '7') + serial_number = get_cybersource_config().get('SERIAL_NUMBER', '') params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time() * 1000) @@ -123,7 +140,7 @@ def get_purchase_params(cart): return params def get_purchase_endpoint(): - return settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') + return get_cybersource_config().get('PURCHASE_ENDPOINT', '') def payment_accepted(params): """ @@ -215,7 +232,9 @@ def record_purchase(params, order): def get_processor_decline_html(params): """Have to parse through the error codes to return a helpful message""" - payment_support_email = settings.PAYMENT_SUPPORT_EMAIL + + # see if we have an override in the microsites + payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) msg = dedent(_( """ @@ -238,7 +257,8 @@ def get_processor_decline_html(params): def get_processor_exception_html(exception): """Return error HTML associated with exception""" - payment_support_email = settings.PAYMENT_SUPPORT_EMAIL + # see if we have an override in the microsites + payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) if isinstance(exception, CCProcessorDataException): msg = dedent(_( """ diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource2.py b/lms/djangoapps/shoppingcart/processors/CyberSource2.py new file mode 100644 index 0000000000000000000000000000000000000000..ecc928f8258d58e45cdfd27d9e66ddea6c6e6ebc --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/CyberSource2.py @@ -0,0 +1,404 @@ +### Implementation of support for the Cybersource Credit card processor using the new +### Secure Acceptance API. The previous Hosted Order Page API is being deprecated as of 9/14 +### It is mostly the same as the CyberSource.py file, but we have a new file so that we can +### maintain some backwards-compatibility in case of a need to quickly roll back (i.e. +### configuration change rather than code rollback ) + +### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting +### Implementes interface as specified by __init__.py + +import hmac +import binascii +import re +import json +import uuid +from datetime import datetime +from collections import OrderedDict, defaultdict +from decimal import Decimal, InvalidOperation +from hashlib import sha256 +from textwrap import dedent +from django.conf import settings +from django.utils.translation import ugettext as _ +from edxmako.shortcuts import render_to_string +from shoppingcart.models import Order +from shoppingcart.processors.exceptions import * +from microsite_configuration import microsite +from django.core.urlresolvers import reverse + + +def get_cybersource_config(): + """ + This method will return any microsite specific cybersource configuration, otherwise + we return the default configuration + """ + config_key = microsite.get_value('cybersource_config_key') + config = {} + if config_key: + # The microsite CyberSource configuration will be subkeys inside of the normal default + # CyberSource configuration + config = settings.CC_PROCESSOR['CyberSource2']['microsites'][config_key] + else: + config = settings.CC_PROCESSOR['CyberSource2'] + + return config + + +def process_postpay_callback(params): + """ + The top level call to this module, basically + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external Hosted Order Page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + try: + result = payment_accepted(params) + if result['accepted']: + # SUCCESS CASE first, rest are some sort of oddity + record_purchase(params, result['order']) + return {'success': True, + 'order': result['order'], + 'error_html': ''} + else: + return {'success': False, + 'order': result['order'], + 'error_html': get_processor_decline_html(params)} + except CCProcessorException as error: + return {'success': False, + 'order': None, # due to exception we may not have the order + 'error_html': get_processor_exception_html(error)} + + +def processor_hash(value): + """ + Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + """ + secret_key = get_cybersource_config().get('SECRET_KEY', '') + hash_obj = hmac.new(secret_key, value, sha256) + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + + +def sign(params, signed_fields_key='signed_field_names', full_sig_key='signature'): + """ + params needs to be an ordered dict, b/c cybersource documentation states that order is important. + Reverse engineered from PHP version provided by cybersource + """ + fields = u",".join(params.keys()) + params[signed_fields_key] = fields + + signed_fields = params.get(signed_fields_key, '').split(',') + values = u",".join([u"{0}={1}".format(i, params.get(i, '')) for i in signed_fields]) + params[full_sig_key] = processor_hash(values) + params[signed_fields_key] = fields + + return params + + +def render_purchase_form_html(cart): + """ + Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource + """ + return render_to_string('shoppingcart/cybersource_form.html', { + 'action': get_purchase_endpoint(), + 'params': get_signed_purchase_params(cart), + }) + + +def get_signed_purchase_params(cart): + """ + This method will return a digitally signed set of CyberSource parameters + """ + return sign(get_purchase_params(cart)) + + +def get_purchase_params(cart): + """ + This method will build out a dictionary of parameters needed by CyberSource to complete the transaction + """ + total_cost = cart.total_cost + amount = "{0:0.2f}".format(total_cost) + params = OrderedDict() + + params['amount'] = amount + params['currency'] = cart.currency + params['orderNumber'] = "OrderId: {0:d}".format(cart.id) + + params['access_key'] = get_cybersource_config().get('ACCESS_KEY', '') + params['profile_id'] = get_cybersource_config().get('PROFILE_ID', '') + params['reference_number'] = cart.id + params['transaction_type'] = 'sale' + + params['locale'] = 'en' + params['signed_date_time'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') + params['signed_field_names'] = 'access_key,profile_id,amount,currency,transaction_type,reference_number,signed_date_time,locale,transaction_uuid,signed_field_names,unsigned_field_names,orderNumber' + params['unsigned_field_names'] = '' + params['transaction_uuid'] = uuid.uuid4() + params['payment_method'] = 'card' + + if hasattr(cart, 'context') and 'request_domain' in cart.context: + params['override_custom_receipt_page'] = '{0}{1}'.format( + cart.context['request_domain'], + reverse('shoppingcart.views.postpay_callback') + ) + + return params + + +def get_purchase_endpoint(): + """ + Helper function to return the CyberSource endpoint configuration + """ + return get_cybersource_config().get('PURCHASE_ENDPOINT', '') + + +def payment_accepted(params): + """ + Check that cybersource has accepted the payment + params: a dictionary of POST parameters returned by CyberSource in their post-payment callback + + returns: true if the payment was correctly accepted, for the right amount + false if the payment was not accepted + + raises: CCProcessorDataException if the returned message did not provide required parameters + CCProcessorWrongAmountException if the amount charged is different than the order amount + + """ + #make sure required keys are present and convert their values to the right type + valid_params = {} + for key, key_type in [('req_reference_number', int), + ('req_currency', str), + ('decision', str)]: + if key not in params: + raise CCProcessorDataException( + _("The payment processor did not return a required parameter: {0}".format(key)) + ) + try: + valid_params[key] = key_type(params[key]) + except ValueError: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + ) + + try: + order = Order.objects.get(id=valid_params['req_reference_number']) + except Order.DoesNotExist: + raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) + + if valid_params['decision'] == 'ACCEPT': + try: + # Moved reading of charged_amount here from the valid_params loop above because + # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter + charged_amt = Decimal(params['auth_amount']) + except InvalidOperation: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format( + params['auth_amount'], 'auth_amount')) + ) + + if charged_amt == order.total_cost and valid_params['req_currency'] == order.currency: + return {'accepted': True, + 'amt_charged': charged_amt, + 'currency': valid_params['req_currency'], + 'order': order} + else: + raise CCProcessorWrongAmountException( + _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}." + .format(charged_amt, valid_params['req_currency'], + order.total_cost, order.currency)) + ) + else: + return {'accepted': False, + 'amt_charged': 0, + 'currency': 'usd', + 'order': order} + + +def record_purchase(params, order): + """ + Record the purchase and run purchased_callbacks + """ + ccnum_str = params.get('req_card_number', '') + mm = re.search("\d", ccnum_str) + if mm: + ccnum = ccnum_str[mm.start():] + else: + ccnum = "####" + + order.purchase( + first=params.get('req_bill_to_forename', ''), + last=params.get('req_bill_to_surname', ''), + street1=params.get('req_bill_to_address_line1', ''), + street2=params.get('req_bill_to_address_line2', ''), + city=params.get('req_bill_to_address_city', ''), + state=params.get('req_bill_to_address_state', ''), + country=params.get('req_bill_to_address_country', ''), + postalcode=params.get('req_bill_to_address_postal_code', ''), + ccnum=ccnum, + cardtype=CARDTYPE_MAP[params.get('req_card_type', '')], + processor_reply_dump=json.dumps(params) + ) + + +def get_processor_decline_html(params): + """Have to parse through the error codes to return a helpful message""" + payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) + + msg = dedent(_( + """ + <p class="error_msg"> + Sorry! Our payment processor did not accept your payment. + The decision they returned was <span class="decision">{decision}</span>, + and the reason was <span class="reason">{reason_code}:{reason_msg}</span>. + You were not charged. Please try a different form of payment. + Contact us with payment-related questions at {email}. + </p> + """)) + + return msg.format( + decision=params['decision'], + reason_code=params['reason_code'], + reason_msg=REASONCODE_MAP[params['reason_code']], + email=payment_support_email + ) + + +def get_processor_exception_html(exception): + """Return error HTML associated with exception""" + + payment_support_email = microsite.get_value('payment_support_email', settings.PAYMENT_SUPPORT_EMAIL) + if isinstance(exception, CCProcessorDataException): + msg = dedent(_( + """ + <p class="error_msg"> + Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! + We apologize that we cannot verify whether the charge went through and take further action on your order. + The specific error message is: <span class="exception_msg">{msg}</span>. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. + </p> + """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorWrongAmountException): + msg = dedent(_( + """ + <p class="error_msg"> + Sorry! Due to an error your purchase was charged for a different amount than the order total! + The specific error message is: <span class="exception_msg">{msg}</span>. + Your credit card has probably been charged. Contact us with payment-specific questions at {email}. + </p> + """.format(msg=exception.message, email=payment_support_email))) + return msg + + # fallthrough case, which basically never happens + return '<p class="error_msg">EXCEPTION!</p>' + + +CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN") +CARDTYPE_MAP.update( + { + '001': 'Visa', + '002': 'MasterCard', + '003': 'American Express', + '004': 'Discover', + '005': 'Diners Club', + '006': 'Carte Blanche', + '007': 'JCB', + '014': 'EnRoute', + '021': 'JAL', + '024': 'Maestro', + '031': 'Delta', + '033': 'Visa Electron', + '034': 'Dankort', + '035': 'Laser', + '036': 'Carte Bleue', + '037': 'Carta Si', + '042': 'Maestro Int.', + '043': 'GE Money UK card' + } +) + +REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON") +REASONCODE_MAP.update( + { + '100': _('Successful transaction.'), + '102': _('One or more fields in the request contains invalid data.'), + '104': dedent(_( + """ + The access_key and transaction_uuid fields for this authorization request matches the access_key and + transaction_uuid of another authorization request that you sent in the last 15 minutes. + Possible fix: retry the payment after 15 minutes. + """)), + '110': _('Only a partial amount was approved.'), + '200': dedent(_( + """ + The authorization request was approved by the issuing bank but declined by CyberSource + becouse it did not pass the Address Verification System (AVS). + """)), + '201': dedent(_( + """ + The issuing bank has questions about the request. You do not receive an + authorization code programmatically, but you might receive one verbally by calling the processor. + Possible fix: retry with another form of payment + """)), + '202': dedent(_( + """ + Expired card. You might also receive this if the expiration date you + provided does not match the date the issuing bank has on file. + Possible fix: retry with another form of payment + """)), + '203': dedent(_( + """ + General decline of the card. No other information provided by the issuing bank. + Possible fix: retry with another form of payment + """)), + '204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'), + # 205 was Stolen or lost card. Might as well not show this message to the person using such a card. + '205': _('Stolen or lost card'), + '207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), + '208': dedent(_( + """ + Inactive card or card not authorized for card-not-present transactions. + Possible fix: retry with another form of payment + """)), + '210': _('The card has reached the credit limit. Possible fix: retry with another form of payment'), + '211': _('Invalid card verification number (CVN). Possible fix: retry with another form of payment'), + # 221 was The customer matched an entry on the processor's negative file. + # Might as well not show this message to the person using such a card. + '221': _('The customer matched an entry on the processors negative file.'), + '222': _('Account frozen. Possible fix: retry with another form of payment'), + '230': dedent(_( + """ + The authorization request was approved by the issuing bank but declined by + CyberSource because it did not pass the CVN check. + Possible fix: retry with another form of payment + """)), + '231': _('Invalid account number. Possible fix: retry with another form of payment'), + '232': dedent(_( + """ + The card type is not accepted by the payment processor. + Possible fix: retry with another form of payment + """)), + '233': _('General decline by the processor. Possible fix: retry with another form of payment'), + '234': dedent(_( + """ + There is a problem with the information in your CyberSource account. Please let us know at {0} + """.format(settings.PAYMENT_SUPPORT_EMAIL))), + '236': _('Processor Failure. Possible fix: retry the payment'), + '240': dedent(_( + """ + The card type sent is invalid or does not correlate with the credit card number. + Possible fix: retry with the same card or another form of payment + """)), + '475': _('The cardholder is enrolled for payer authentication'), + '476': _('Payer authentication could not be authenticated'), + '520': dedent(_( + """ + The authorization request was approved by the issuing bank but declined by CyberSource based + on your legacy Smart Authorization settings. + Possible fix: retry with a different form of payment. + """)), + } +) diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index 6f708f3bc3d70c4f3706031780b7a6ca0d3dd3b1..8d1c9aeb51985850ea6f24e756f7d3cfe04d3330 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -10,6 +10,8 @@ from shoppingcart.models import Order, OrderItem from shoppingcart.processors.CyberSource import * from shoppingcart.processors.exceptions import * from mock import patch, Mock +from microsite_configuration import microsite +import mock TEST_CC_PROCESSOR = { @@ -19,10 +21,28 @@ TEST_CC_PROCESSOR = { 'SERIAL_NUMBER': '12345', 'ORDERPAGE_VERSION': '7', 'PURCHASE_ENDPOINT': '', + 'microsites': { + 'test_microsite': { + 'SHARED_SECRET': 'secret_override', + 'MERCHANT_ID': 'edx_test_override', + 'SERIAL_NUMBER': '12345_override', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } + } } } +def fakemicrosite(name, default=None): + """ + This is a test mocking function to return a microsite configuration + """ + if name == 'cybersource_config_key': + return 'test_microsite' + else: + return None + @override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) class CyberSourceTests(TestCase): @@ -33,6 +53,15 @@ class CyberSourceTests(TestCase): self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + def test_microsite_no_override_settings(self): + self.assertEqual(get_cybersource_config()['MERCHANT_ID'], 'edx_test') + self.assertEqual(get_cybersource_config()['SHARED_SECRET'], 'secret') + + @mock.patch("microsite_configuration.microsite.get_value", fakemicrosite) + def test_microsite_override_settings(self): + self.assertEqual(get_cybersource_config()['MERCHANT_ID'], 'edx_test_override') + self.assertEqual(get_cybersource_config()['SHARED_SECRET'], 'secret_override') + def test_hash(self): """ Tests the hash function. Basically just hardcodes the answer. diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index fc7cd7158d40d2b0453b1d0d2563de30e5ee979e..8f0e93f8095bafdc7382a024a1d01df4f8516bf6 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -14,7 +14,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from shoppingcart.views import _can_download_report, _get_date_from_str -from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration +from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, Coupon from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode @@ -22,7 +22,8 @@ from edxmako.shortcuts import render_to_response from shoppingcart.processors import render_purchase_form_html from mock import patch, Mock from shoppingcart.views import initialize_report - +from decimal import Decimal +from student.tests.factories import AdminFactory def mock_render_purchase_form_html(*args, **kwargs): return render_purchase_form_html(*args, **kwargs) @@ -45,7 +46,10 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.user = UserFactory.create() self.user.set_password('password') self.user.save() + self.instructor = AdminFactory.create() self.cost = 40 + self.coupon_code = 'abcde' + self.percentage_discount = 10 self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') self.course_key = self.course.id self.course_mode = CourseMode(course_id=self.course_key, @@ -58,6 +62,29 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.cart = Order.get_cart_for_user(self.user) self.addCleanup(patcher.stop) + def get_discount(self): + """ + This method simple return the discounted amount + """ + val = Decimal("{0:.2f}".format(Decimal(self.percentage_discount / 100.00) * self.cost)) + return self.cost - val + + def add_coupon(self, course_key, is_active): + """ + add dummy coupon into models + """ + coupon = Coupon(code=self.coupon_code, description='testing code', course_id=course_key, + percentage_discount=self.percentage_discount, created_by=self.user, is_active=is_active) + coupon.save() + + def add_course_to_user_cart(self): + """ + adding course to user cart + """ + self.login_user() + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_key) + return reg_item + def login_user(self): self.client.login(username=self.user.username, password="password") @@ -72,6 +99,141 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 400) self.assertIn('The course {0} is already in your cart.'.format(self.course_key.to_deprecated_string()), resp.content) + def test_course_discount_invalid_coupon(self): + self.add_coupon(self.course_key, True) + self.add_course_to_user_cart() + non_existing_code = "non_existing_code" + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': non_existing_code}) + self.assertEqual(resp.status_code, 404) + self.assertIn("Discount does not exist against coupon '{0}'.".format(non_existing_code), resp.content) + + def test_course_discount_inactive_coupon(self): + self.add_coupon(self.course_key, False) + self.add_course_to_user_cart() + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 400) + self.assertIn("Coupon '{0}' is inactive.".format(self.coupon_code), resp.content) + + def test_course_does_not_exist_in_cart_against_valid_coupon(self): + course_key = self.course_key.to_deprecated_string() + 'testing' + self.add_coupon(course_key, True) + self.add_course_to_user_cart() + + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 404) + self.assertIn("Coupon '{0}' is not valid for any course in the shopping cart.".format(self.coupon_code), resp.content) + + def test_course_discount_for_valid_active_coupon_code(self): + + self.add_coupon(self.course_key, True) + self.add_course_to_user_cart() + + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + + # unit price should be updated for that course + item = self.cart.orderitem_set.all().select_subclasses()[0] + self.assertEquals(item.unit_cost, self.get_discount()) + + # after getting 10 percent discount + self.assertEqual(self.cart.total_cost, self.get_discount()) + + # now testing coupon code already used scenario, reusing the same coupon code + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 400) + self.assertIn("Coupon '{0}' already used.".format(self.coupon_code), resp.content) + + @patch('shoppingcart.views.log.debug') + def test_non_existing_coupon_redemption_on_removing_item(self, debug_log): + + reg_item = self.add_course_to_user_cart() + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': reg_item.id}) + debug_log.assert_called_with( + 'Coupon redemption does not exist for order item id={0}.'.format(reg_item.id)) + + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 0) + + @patch('shoppingcart.views.log.info') + def test_existing_coupon_redemption_on_removing_item(self, info_log): + + self.add_coupon(self.course_key, True) + reg_item = self.add_course_to_user_cart() + + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': reg_item.id}) + + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 0) + info_log.assert_called_with( + 'Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"'.format(self.coupon_code, self.user, reg_item.id)) + + @patch('shoppingcart.views.log.info') + def test_coupon_discount_for_multiple_courses_in_cart(self, info_log): + + reg_item = self.add_course_to_user_cart() + self.add_coupon(self.course_key, True) + cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') + self.assertEquals(self.cart.orderitem_set.count(), 2) + + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + + # unit_cost should be updated for that particular course for which coupon code is registered + items = self.cart.orderitem_set.all().select_subclasses() + for item in items: + if item.id == reg_item.id: + self.assertEquals(item.unit_cost, self.get_discount()) + elif item.id == cert_item.id: + self.assertEquals(item.list_price, None) + + # Delete the discounted item, corresponding coupon redemption should be removed for that particular discounted item + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': reg_item.id}) + + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 1) + info_log.assert_called_with( + 'Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"'.format(self.coupon_code, self.user, reg_item.id)) + + @patch('shoppingcart.views.log.info') + def test_delete_certificate_item(self, info_log): + + reg_item = self.add_course_to_user_cart() + cert_item = CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') + self.assertEquals(self.cart.orderitem_set.count(), 2) + + # Delete the discounted item, corresponding coupon redemption should be removed for that particular discounted item + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': cert_item.id}) + + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 1) + info_log.assert_called_with( + 'order item {0} removed for user {1}'.format(cert_item.id, self.user)) + + @patch('shoppingcart.views.log.info') + def test_remove_coupon_redemption_on_clear_cart(self, info_log): + + reg_item = self.add_course_to_user_cart() + CertificateItem.add_to_order(self.cart, self.verified_course_key, self.cost, 'honor') + self.assertEquals(self.cart.orderitem_set.count(), 2) + + self.add_coupon(self.course_key, True) + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + + resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 0) + + info_log.assert_called_with( + 'Coupon redemption entry removed for user {0} for order {1}'.format(self.user, reg_item.id)) + def test_add_course_to_cart_already_registered(self): CourseEnrollment.enroll(self.user, self.course_key) self.login_user() @@ -188,6 +350,41 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): resp2 = self.client.get(reverse('shoppingcart.views.show_receipt', args=[1000])) self.assertEqual(resp2.status_code, 404) + def test_total_amount_of_purchased_course(self): + self.add_course_to_user_cart() + self.assertEquals(self.cart.orderitem_set.count(), 1) + self.add_coupon(self.course_key, True) + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + # Total amount of a particular course that is purchased by different users + total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course_key) + self.assertEqual(total_amount, 36) + + self.client.login(username=self.instructor.username, password="test") + cart = Order.get_cart_for_user(self.instructor) + PaidCourseRegistration.add_to_order(cart, self.course_key) + cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + total_amount = PaidCourseRegistration.get_total_amount_of_purchased_item(self.course_key) + self.assertEqual(total_amount, 76) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success_with_valid_coupon_code(self): + self.add_course_to_user_cart() + self.add_coupon(self.course_key, True) + + resp = self.client.post(reverse('shoppingcart.views.use_coupon'), {'coupon_code': self.coupon_code}) + self.assertEqual(resp.status_code, 200) + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('FirstNameTesting123', resp.content) + self.assertIn(str(self.get_discount()), resp.content) + @patch('shoppingcart.views.render_to_response', render_mock) def test_show_receipt_success(self): reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_key) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 2dd083c88c7a8a6707b7c2273e6ba9f16635d87c..14ea008f97e345a4b2d5458008a492beec52e2ab 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -14,6 +14,7 @@ if settings.FEATURES['ENABLE_SHOPPING_CART']: url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^add/course/{}/$'.format(settings.COURSE_ID_PATTERN), 'add_course_to_cart', name='add_course_to_cart'), + url(r'^use_coupon/$', 'use_coupon'), ) if settings.FEATURES.get('ENABLE_PAYMENT_FAKE'): diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a01648e0cb0edf08800815e364e9c7b8027ede57..7f60780cc0125522eae48cb70c74339e8b578ede 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -14,9 +14,10 @@ from edxmako.shortcuts import render_to_response from opaque_keys.edx.locations import SlashSeparatedCourseKey from shoppingcart.reports import RefundReport, ItemizedPurchaseReport, UniversityRevenueShareReport, CertificateStatusReport from student.models import CourseEnrollment -from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException -from .models import Order, PaidCourseRegistration, OrderItem +from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException, ReportTypeDoesNotExistException, CouponAlreadyExistException, ItemDoesNotExistAgainstCouponException +from .models import Order, PaidCourseRegistration, OrderItem, Coupon, CouponRedemption from .processors import process_postpay_callback, render_purchase_form_html +import json log = logging.getLogger("shoppingcart") @@ -69,6 +70,16 @@ def show_cart(request): cart = Order.get_cart_for_user(request.user) total_cost = cart.total_cost cart_items = cart.orderitem_set.all() + + # add the request protocol, domain, and port to the cart object so that any specific + # CC_PROCESSOR implementation can construct callback URLs, if necessary + cart.context = { + 'request_domain': '{0}://{1}'.format( + 'https' if request.is_secure() else 'http', + request.get_host() + ) + } + form_html = render_purchase_form_html(cart) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, @@ -81,6 +92,11 @@ def show_cart(request): def clear_cart(request): cart = Order.get_cart_for_user(request.user) cart.clear() + coupon_redemption = CouponRedemption.objects.filter(user=request.user, order=cart.id) + if coupon_redemption: + coupon_redemption.delete() + log.info('Coupon redemption entry removed for user {0} for order {1}'.format(request.user, cart.id)) + return HttpResponse('Cleared') @@ -90,12 +106,50 @@ def remove_item(request): try: item = OrderItem.objects.get(id=item_id, status='cart') if item.user == request.user: + order_item_course_id = None + if hasattr(item, 'paidcourseregistration'): + order_item_course_id = item.paidcourseregistration.course_id item.delete() + log.info('order item {0} removed for user {1}'.format(item_id, request.user)) + try: + coupon_redemption = CouponRedemption.objects.get(user=request.user, order=item.order_id) + if order_item_course_id == coupon_redemption.coupon.course_id: + coupon_redemption.delete() + log.info('Coupon "{0}" redemption entry removed for user "{1}" for order item "{2}"' + .format(coupon_redemption.coupon.code, request.user, item_id)) + except CouponRedemption.DoesNotExist: + log.debug('Coupon redemption does not exist for order item id={0}.'.format(item_id)) except OrderItem.DoesNotExist: log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') +@login_required +def use_coupon(request): + """ + This method generate discount against valid coupon code and save its entry into coupon redemption table + """ + coupon_code = request.POST["coupon_code"] + try: + coupon = Coupon.objects.get(code=coupon_code) + except Coupon.DoesNotExist: + return HttpResponseNotFound(_("Discount does not exist against coupon '{0}'.".format(coupon_code))) + + if coupon.is_active: + try: + cart = Order.get_cart_for_user(request.user) + CouponRedemption.add_coupon_redemption(coupon, cart) + except CouponAlreadyExistException: + return HttpResponseBadRequest(_("Coupon '{0}' already used.".format(coupon_code))) + except ItemDoesNotExistAgainstCouponException: + return HttpResponseNotFound(_("Coupon '{0}' is not valid for any course in the shopping cart.".format(coupon_code))) + + response = HttpResponse(json.dumps({'response': 'success'}), content_type="application/json") + return response + else: + return HttpResponseBadRequest(_("Coupon '{0}' is inactive.".format(coupon_code))) + + @csrf_exempt @require_POST def postpay_callback(request): @@ -122,6 +176,7 @@ def show_receipt(request, ordernum): Displays a receipt for a particular order. 404 if order is not yet purchased or request.user != order.user """ + try: order = Order.objects.get(id=ordernum) except Order.DoesNotExist: diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index d1c3964d9330cb54deaca0a72af2d629cd67f6cd..76a491e1cd4669aea96fa737da5313f8a85f8ab1 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -597,7 +597,7 @@ section.instructor-dashboard-content-2 { float: left; clear: both; margin-top: 25px; - + .metrics-left, .metrics-left-header { position: relative; width: 30%; @@ -611,7 +611,7 @@ section.instructor-dashboard-content-2 { .metrics-section.metrics-left { height: 640px; } - + .metrics-right, .metrics-right-header { position: relative; width: 65%; @@ -627,7 +627,7 @@ section.instructor-dashboard-content-2 { .metrics-section.metrics-right { height: 295px; } - + svg { .stacked-bar { cursor: pointer; @@ -775,3 +775,267 @@ input[name="subject"] { font-weight: bold; } } + +.ecommerce-wrapper{ + h2{ + height: 26px; + line-height: 26px; + span{ + float: right; + font-size: 16px; + font-weight: bold; + span{ + background: #ddd; + padding: 2px 9px; + border-radius: 2px; + float: none; + font-weight: 400; + } + } + } + span.tip{ + padding: 10px 15px; + display: block; + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + background: #f8f4ec; + color: #3c3c3c; + line-height: 30px; + .add{ + @include button(simple, $blue); + @extend .button-reset; + font-size: em(13); + float: right; + } + } + +} +#e-commerce{ + .coupon-errors { + background: #FFEEF5;color:#B72667;text-align: center;padding: 10px 0px; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif;font-size: 15px; + border-bottom: 1px solid #B72667; + margin-bottom: 20px; + display: none; + } + .content{ + padding: 0 !important; + } + .coupons-table { + width: 100%; + tr:nth-child(even){ + background-color: #f8f8f8; + border-bottom: 1px solid #f3f3f3; + } + tr.always-gray{ + background: #eee !important; + border-top: 2px solid #FFFFFF; + } + tr.always-white{ + background: #fff !important; + td{ + padding: 30px 0px 10px; + } + } + .coupons-headings { + height: 40px; + border-bottom: 1px solid #BEBEBE; + + th:nth-child(5){ + text-align: center; + width: 120px; + } + th:first-child{ + padding-left: 20px; + } + th { + text-align: left; + border-bottom: 1px solid $border-color-1; + + &.c_code { + width: 170px; + } + &.c_count { + width: 85px; + } + &.c_course_id { + width: 320px; + word-wrap: break-word; + } + &.c_discount { + width: 90px; + } + &.c_action { + width: 89px; + } + &.c_dsc{ + width: 260px; + word-wrap: break-word; + } + } + } + + // in_active coupon rows style + .inactive_coupon{ + background: #FFF0F0 !important; + text-decoration: line-through; + color: rgba(51,51,51,0.2); + border-bottom: 1px solid #fff; + td { + a { + color: rgba(51,51,51,0.2); + } + } + } + + // coupon items style + .coupons-items { + td { + padding: 10px 0px; + position: relative; + line-height: normal; + span.old-price{ + left: -75px; + position: relative; + text-decoration: line-through; + color: red; + font-size: 12px; + top: -1px; + } + } + td:nth-child(5),td:first-child{ + padding-left: 20px; + } + td:nth-child(2){ + line-height: 22px; + padding-right: 0px; + word-wrap: break-word; + } + td:nth-child(5){ + padding-left: 0; + text-align: center; + } + td{ + a.edit-right{ + margin-left: 15px; + } + } + } + } + + // coupon edit and add modals + #add-coupon-modal, #edit-coupon-modal{ + .inner-wrapper { + background: #fff; + } + top:-95px !important; + width: 650px; + margin-left: -325px; + border-radius: 2px; + input[type="submit"]#update_coupon_button{ + @include button(simple, $blue); + @extend .button-reset; + } + input[type="submit"]#add_coupon_button{ + @include button(simple, $blue); + @extend .button-reset; + } + .modal-form-error { + box-shadow: inset 0 -1px 2px 0 #f3d9db; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + margin: 20px 0 10px 0 !important; + padding: 20px; + border: none; + border-bottom: 3px solid #a0050e; + background: #fbf2f3; + } + ol.list-input{ + li{ + width: 278px; + float: left; + label.required:after { + content: "*"; + margin-left: 5px; + } + } + li:nth-child(even){ + margin-left: 30px !important; + } + li:nth-child(3), li:nth-child(4){ + margin-left: 0px !important; + width: 100%; + } + li:nth-child(3) { + margin-bottom: 0px !important; + textarea { + min-height: 100px; + } + } + li:last-child{ + margin-bottom: 0px !important; + } + + } + #coupon-content { + padding: 20px; + header { + margin: 0; + padding: 0; + h2 { + font-size: 24px; + font-weight: 100; + color: #1580b0; + text-align: left; + } + } + .instructions p { + margin-bottom: 5px; + } + form { + border-radius: 0; + box-shadow: none; + margin: 0; + border: none; + padding: 0; + .group-form { + margin: 0; + padding-top: 0; + padding-bottom: 20px; + } + .list-input { + margin: 0; + padding: 0; + list-style: none; + } + .readonly { + background-color: #eee !important; + color: #aaa; + } + .field { + margin: 0 0 20px 0; + } + .field.required label { + font-weight: 600; + } + .field label { + -webkit-transition: color 0.15s ease-in-out 0s; + -moz-transition: color 0.15s ease-in-out 0s; + transition: color 0.15s ease-in-out 0s; + margin: 0 0 5px 0; + color: #333; + } + .field.text input { + background: #fff; + margin-bottom: 0; + } + .field input { + width: 100%; + margin: 0; + padding: 10px 15px; + } + } + } + } +} diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index 1b3da66893dfea5ac3303d5afab73460c4e38862..6c0808518142d0dc84c7b891596f191e1adef65e 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -12,14 +12,20 @@ border: 1px solid $red; } - +.cart-errors{ + background: #FFEEF5;color:#B72667;text-align: center;padding: 10px 0px; + font-family: "Open Sans",Verdana,Geneva,sans-serif,sans-serif;font-size: 15px; + border-bottom: 1px solid #B72667; + margin-bottom: 20px; + display: none; +} .cart-list { padding: 30px; margin-top: 40px; border-radius: 3px; border: 1px solid $border-color-1; background-color: $action-primary-fg; - + > h2 { font-size: 1.5em; color: $base-font-color; @@ -27,13 +33,42 @@ .cart-table { width: 100%; - + tr:nth-child(even){ + background-color: #f8f8f8; + border-bottom: 1px solid #f3f3f3; + } + tr.always-gray{ + background: #eee !important; + border-top: 2px solid #FFFFFF; + } + tr.always-white{ + background: #fff !important; + td{ + padding: 30px 0px 10px; + } + } + tr{ + td.cart-total{ + padding: 10px 0; + span{ + display: inline-block; + margin-right: 15px; + margin-left: 15px; + font-weight: bold; + } + } + + } .cart-headings { height: 35px; - + border-bottom: 1px solid #BEBEBE; + + th:nth-child(5),th:first-child{ + text-align: center; + width: 120px; + } th { text-align: left; - padding-left: 5px; border-bottom: 1px solid $border-color-1; &.qty { @@ -48,12 +83,35 @@ &.cur { width: 100px; } + &.dsc{ + width: 640px; + padding-right: 50px; + } } } .cart-items { td { - padding: 10px 25px; + padding: 10px 0px; + position: relative; + line-height: normal; + span.old-price{ + left: -75px; + position: relative; + text-decoration: line-through; + color: red; + font-size: 12px; + top: -1px; + } + } + td:nth-child(5),td:first-child{ + text-align: center; + + + } + td:nth-child(2){ + line-height: 22px; + padding-right: 50px; } } @@ -64,6 +122,7 @@ font-weight: bold; padding: 10px 25px; } + } } } @@ -80,11 +139,10 @@ .items-ordered { padding-top: 50px; } - + tr { - } - + th { text-align: left; padding: 25px 0 15px 0; @@ -105,6 +163,9 @@ tr.order-item { td { padding-bottom: 10px; + span.old-price{ + text-decoration: line-through !important; + } } } } diff --git a/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html b/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html new file mode 100644 index 0000000000000000000000000000000000000000..8befb90f109de7dfb6e47cfb5fad7b0342f28a8b --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html @@ -0,0 +1,62 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%page args="section_data"/> +<section id="add-coupon-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Password Reset')}"> + <div class="inner-wrapper"> + <button class="close-modal"> + <i class="icon-remove"></i> + <span class="sr"> + ## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen) + ${_('Close')} + </span> + </button> + + <div id="coupon-content"> + <header> + <h2>${_("Add Coupon")}</h2> + </header> + + <div class="instructions"> + <p> + ${_("Please enter Coupon detail below")}</p> + </div> + + <form id="add_coupon_form" action="${section_data['ajax_add_coupon']}" method="post" data-remote="true"> + <div id="coupon_form_error" class="modal-form-error"></div> + <fieldset class="group group-form group-form-requiredinformation"> + <legend class="is-hidden">${_("Required Information")}</legend> + + <ol class="list-input"> + <li class="field required text" id="add-coupon-modal-field-code"> + <label for="coupon_code" class="required">${_("Code")}</label> + <input class="" id="coupon_code" type="text" name="code" maxlength="16" value="" placeholder="example: A123DS" + aria-required="true"/> + </li> + <li class="field required text" id="add-coupon-modal-field-discount"> + <label for="coupon_discount" class="required text">${_("Percentage Discount")}</label> + <input class="field required" id="coupon_discount" type="text" name="discount" value="" maxlength="5" + aria-required="true"/> + </li> + + <li class="field" id="add-coupon-modal-field-description"> + <label for="coupon_description">${_("Description")}</label> + <textarea class="field" id="coupon_description" type="text" name="description" value="" + aria-describedby="pwd_reset_email-tip" aria-required="true"> </textarea> + </li> + + <li class="field" id="add-coupon-modal-field-course_id"> + <label for="coupon_course_id">${_("Course ID")}</label> + <input class="field readonly" id="coupon_course_id" type="text" name="course_id" value="${section_data['course_id']}" + readonly aria-required="true"/> + </li> + + </ol> + </fieldset> + + <div class="submit"> + <input name="submit" type="submit" id="add_coupon_button" value="${_('Add Coupon')}"/> + </div> + </form> + </div> + </div> +</section> diff --git a/lms/templates/instructor/instructor_dashboard_2/e-commerce.html b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html new file mode 100644 index 0000000000000000000000000000000000000000..90ec854828490384a8f4d6ea8eec35de62a9b00c --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/e-commerce.html @@ -0,0 +1,256 @@ +<%! from django.utils.translation import ugettext as _ %> +<%page args="section_data"/> +<%include file="add_coupon_modal.html" args="section_data=section_data" /> +<%include file="edit_coupon_modal.html" args="section_data=section_data" /> + +<div class="ecommerce-wrapper"> + <h2>${_("Coupons List")} + %if section_data['total_amount'] is not None: + <span>${_("Total Amount: ")}<span>$${section_data['total_amount']}</span></span> + %endif + </h2> + + <h3 class="coupon-errors" id="coupon-error"></h3> + <span class="tip">${_("Coupons Information")} <a id="add_coupon_link" href="#add-coupon-modal" rel="leanModal" + class="add blue-button">${_("+ Add Coupon")}</a></span> +</div> +<div class="wrapper-content wrapper"> + <section class="content"> + %if len(section_data['coupons']): + <table class="coupons-table"> + <thead> + <tr class="coupons-headings"> + <th class="c_code">${_("Code")}</th> + <th class="c_dsc">${_("Description")}</th> + <th class="c_course_id">${_("Course_id")}</th> + <th class="c_discount">${_("Discount(%)")}</th> + <th class="c_count">${_("Count")}</th> + <th class="c_action">${_("Actions")}</th> + </tr> + </thead> + + <tbody> + %for coupon in section_data['coupons']: + %if coupon.is_active == False: + <tr class="coupons-items inactive_coupon"> + %else: + <tr class="coupons-items"> + %endif + <td>${coupon.code}</td> + + <td>${coupon.description}</td> + <td>${coupon.course_id.to_deprecated_string()}</td> + <td>${coupon.percentage_discount}</td> + <td> + ${ coupon.couponredemption_set.all().count() } + </td> + <!--<td>${coupon.is_active}</td>--> + <td><a data-item-id="${coupon.id}" class='remove_coupon' href='#'>[x]</a><a href="#edit-modal" data-item-id="${coupon.id}" class="edit-right">Edit</a></td> + </tr> + %endfor + </tbody> + </table> + <a id="edit-modal-trigger" href="#edit-coupon-modal" rel="leanModal"></a> + %endif + </section> +</div> + + +<script> + $(function () { + $('a[rel*=leanModal]').leanModal(); + $.each($("a.edit-right"), function () { + if ($(this).parent().parent('tr').hasClass('inactive_coupon')) { + $(this).removeAttr('href') + } + }); + $.each($("a.remove_coupon"), function () { + if ($(this).parent().parent('tr').hasClass('inactive_coupon')) { + $(this).removeAttr('href') + } + }); + $('a.edit-right').click(function (event) { + $('#edit_coupon_form #coupon_form_error').attr('style', 'display: none'); + $('#edit_coupon_form #coupon_form_error').text(); + event.preventDefault(); + event.stopPropagation(); + var coupon_id = $(this).data('item-id'); + $('#coupon_id').val(coupon_id); + if ($(this).parent().parent('tr').hasClass('inactive_coupon')) { + return false; + } + $.ajax({ + type: "POST", + data: {id: coupon_id}, + url: "${section_data['ajax_get_coupon_info']}", + success: function (data) { + $('#coupon-error').val(''); + $('#coupon-error').attr('style', 'display: none'); + $('input#edit_coupon_code').val(data.coupon_code); + $('input#edit_coupon_discount').val(data.coupon_discount); + $('textarea#edit_coupon_description').val(data.coupon_description); + $('input#edit_coupon_course_id').val(data.coupon_course_id); + $('#edit-modal-trigger').click(); + }, + error: function(jqXHR, textStatus, errorThrown) { + var data = $.parseJSON(jqXHR.responseText); + $('#coupon-error').html(data.message).show(); + } + }); + }); + $('a.remove_coupon').click(function (event) { + var anchor = $(this); + if (anchor.data("disabled")) { + return false; + } + anchor.data("disabled", "disabled"); + event.preventDefault(); + if ($(this).parent().parent('tr').hasClass('inactive_coupon')) { + return false; + } + $.ajax({ + type: "POST", + data: {id: $(this).data('item-id')}, + url: "${section_data['ajax_remove_coupon_url']}", + success: function (data) { + anchor.removeData("disabled"); + location.reload(true); + }, + error: function(jqXHR, textStatus, errorThrown) { + var data = $.parseJSON(jqXHR.responseText); + $('#coupon-error').html(data.message).show(); + anchor.removeData("disabled"); + } + }); + }); + $('#edit_coupon_form').submit(function () { + $("#update_coupon_button").attr('disabled', true); + // Get the Code and Discount value and trim it + var code = $.trim($('#edit_coupon_code').val()); + var coupon_discount = $.trim($('#edit_coupon_discount').val()); + + // Check if empty of not + if (code === '') { + $('#edit_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#edit_coupon_form #coupon_form_error').text("${_('Please Enter the Coupon Code')}"); + $("#update_coupon_button").removeAttr('disabled'); + return false; + } + if (coupon_discount == '0') { + $('#edit_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#edit_coupon_form #coupon_form_error').text("${_('Please Enter the Value Greater than 0')}"); + $("#update_coupon_button").removeAttr('disabled'); + return false; + } + if (!$.isNumeric(coupon_discount)) { + $('#edit_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#edit_coupon_form #coupon_form_error').text("${_('Please Enter the Coupon Discount Value Greater than 0')}"); + $("#update_coupon_button").removeAttr('disabled'); + return false; + } + }); + $('#add_coupon_link').click(function () { + reset_input_fields(); + }); + $('#add_coupon_form').submit(function () { + $("#add_coupon_button").attr('disabled', true); + // Get the Code and Discount value and trim it + var code = $.trim($('#coupon_code').val()); + var coupon_discount = $.trim($('#coupon_discount').val()); + + // Check if empty of not + if (code === '') { + $("#add_coupon_button").removeAttr('disabled'); + $('#add_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#add_coupon_form #coupon_form_error').text("${_('Please Enter the Coupon Code')}"); + return false; + } + if (coupon_discount == '0') { + $('#add_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#add_coupon_form #coupon_form_error').text("${_('Please Enter the Coupon Discount Value Greater than 0')}"); + $("#add_coupon_button").removeAttr('disabled'); + return false; + } + if (!$.isNumeric(coupon_discount)) { + $("#add_coupon_button").removeAttr('disabled'); + $('#add_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#add_coupon_form #coupon_form_error').text("${_('Please Enter the Numeric value for Discount')}"); + return false; + } + }); + + $('#add_coupon_form').on('ajax:complete', function (event, xhr) { + if (xhr.status == 200) { + location.reload(true); + } else { + $("#add_coupon_button").removeAttr('disabled'); + $('#add_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#add_coupon_form #coupon_form_error').text(xhr.responseText); + } + }); + + $('#edit_coupon_form').on('ajax:complete', function (event, xhr) { + if (xhr.status == 200) { + location.reload(true); + } else { + $("#update_coupon_button").removeAttr('disabled'); + $('#edit_coupon_form #coupon_form_error').attr('style', 'display: block !important'); + $('#edit_coupon_form #coupon_form_error').text(xhr.responseText); + } + }); + // removing close link's default behavior + $('.close-modal').click(function (e) { + $("#update_coupon_button").removeAttr('disabled'); + $("#add_coupon_button").removeAttr('disabled'); + reset_input_fields(); + e.preventDefault(); + }); + + var onModalClose = function () { + $("#add-coupon-modal").attr("aria-hidden", "true"); + $(".remove_coupon").focus(); + $("#edit-coupon-modal").attr("aria-hidden", "true"); + $(".edit-right").focus(); + $("#add_coupon_button").removeAttr('disabled'); + $("#update_coupon_button").removeAttr('disabled'); + reset_input_fields(); + }; + + var cycle_modal_tab = function (from_element_name, to_element_name) { + $(from_element_name).on('keydown', function (e) { + var keyCode = e.keyCode || e.which; + var TAB_KEY = 9; // 9 corresponds to the tab key + if (keyCode === TAB_KEY) { + e.preventDefault(); + $(to_element_name).focus(); + } + }); + }; + + $("#add-coupon-modal .close-modal").click(onModalClose); + $("#edit-coupon-modal .close-modal").click(onModalClose); + $("#add-coupon-modal .close-modal").click(reset_input_fields); + + + // Hitting the ESC key will exit the modal + $("#add-coupon-modal, #edit-coupon-modal").on("keydown", function (e) { + var keyCode = e.keyCode || e.which; + // 27 is the ESC key + if (keyCode === 27) { + e.preventDefault(); + $("#add-coupon-modal .close-modal").click(); + $("#edit-coupon-modal .close-modal").click(); + } + }); + }); + var reset_input_fields = function () { + $('#coupon-error').val(''); + $('#coupon-error').attr('style', 'display: none'); + $('#add_coupon_form #coupon_form_error').attr('style', 'display: none'); + $('#add_coupon_form #coupon_form_error').text(); + $('input#coupon_code').val(''); + $('input#coupon_discount').val(''); + $('textarea#coupon_description').val(''); + + } +</script> diff --git a/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html b/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html new file mode 100644 index 0000000000000000000000000000000000000000..2d292abf129a8118e991a20078a31e84d11a1d0e --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html @@ -0,0 +1,63 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%page args="section_data"/> +<section id="edit-coupon-modal" class="modal" role="dialog" tabindex="-1" aria-label="${_('Edit Coupon')}"> + <div class="inner-wrapper"> + <button class="close-modal"> + <i class="icon-remove"></i> + <span class="sr"> + ## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen) + ${_('Close')} + </span> + </button> + + <div id="coupon-content"> + <header> + <h2>${_("Update Coupon")}</h2> + </header> + + <div class="instructions"> + <p> + ${_("Update Coupon Information")}</p> + </div> + + <form id="edit_coupon_form" action="${section_data['ajax_update_coupon']}" method="post" data-remote="true"> + <div id="coupon_form_error" class="modal-form-error"></div> + <fieldset class="group group-form group-form-requiredinformation"> + <legend class="is-hidden">${_("Required Information")}</legend> + + <ol class="list-input"> + <li class="field required text" id="edit-coupon-modal-field-code"> + <label for="edit_coupon_code" class="required">${_("Code")}</label> + <input class="field" id="edit_coupon_code" type="text" name="code" maxlength="16" value="" placeholder="example: A123DS" + aria-required="true"/> + </li> + <li class="field required text" id="edit-coupon-modal-field-discount"> + <label for="edit_coupon_discount" class="required">${_("Percentage Discount")}</label> + <input class="field" id="edit_coupon_discount" type="text" name="discount" value="" maxlength="5" + aria-required="true"/> + </li> + + <li class="field" id="edit-coupon-modal-field-description"> + <label for="edit_coupon_description">${_("Description")}</label> + <textarea class="field" id="edit_coupon_description" type="text" name="description" value="" + aria-required="true"></textarea> + </li> + + <li class="field" id="edit-coupon-modal-field-course_id"> + <label for="edit_coupon_course_id">${_("Course ID")}</label> + <input class="field readonly" id="edit_coupon_course_id" type="text" name="course_id" value="" + readonly aria-required="true"/> + </li> + + </ol> + </fieldset> + + <div class="submit"> + <input type="hidden" name="coupon_id" id="coupon_id"/> + <input name="submit" type="submit" id="update_coupon_button" value="${_('Update Coupon')}"/> + </div> + </form> + </div> + </div> +</section> diff --git a/lms/templates/shoppingcart/cybersource_form.html b/lms/templates/shoppingcart/cybersource_form.html index b29ea79aa167bcc6e51ece152f25dcf5f49f56e9..b07af57ab7d24d6088926dd617962cd4638a0998 100644 --- a/lms/templates/shoppingcart/cybersource_form.html +++ b/lms/templates/shoppingcart/cybersource_form.html @@ -2,5 +2,6 @@ % for pk, pv in params.iteritems(): <input type="hidden" name="${pk}" value="${pv}" /> % endfor + <input type="submit" value="Check Out" /> - </form> + </form> \ No newline at end of file diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 89eeb0d1ccf9c78fda2573c99b8949a059157223..060a813c7edb8fb120f4750da0b01275c71ee2a2 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -8,6 +8,7 @@ <section class="container cart-list"> <h2>${_("Your selected items:")}</h2> + <h3 class="cart-errors" id="cart-error">Error goes here.</h3> % if shoppingcart_items: <table class="cart-table"> <thead> @@ -24,24 +25,39 @@ <tr class="cart-items"> <td>${item.qty}</td> <td>${item.line_desc}</td> - <td>${"{0:0.2f}".format(item.unit_cost)}</td> + <td> + ${"{0:0.2f}".format(item.unit_cost)} + % if item.list_price != None: + <span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span> + % endif + </td> <td>${"{0:0.2f}".format(item.line_cost)}</td> <td>${item.currency.upper()}</td> <td><a data-item-id="${item.id}" class='remove_line_item' href='#'>[x]</a></td> </tr> % endfor - <tr class="cart-headings"> - <td colspan="4"></td> - <th>${_("Total Amount")}</th> - </tr> - <tr class="cart-totals"> - <td colspan="4"></td> - <td class="cart-total-cost">${"{0:0.2f}".format(amount)}</td> + <tr class="always-gray"> + <td colspan="3"></td> + <td colspan="3" valign="middle" class="cart-total" align="right"> + <b>${_("Total Amount")}: <span> ${"{0:0.2f}".format(amount)} </span> </b> + </td> </tr> + </tbody> + <tfoot> + <tr class="always-white"> + <td colspan="2"> + <input type="text" placeholder="Enter coupon code here" name="coupon_code" id="couponCode"> + <input type="button" value="Use Coupon" id="cart-coupon"> + </td> + <td colspan="4" align="right"> + ${form_html} + </td> + </tr> + + </tfoot> </table> <!-- <input id="back_input" type="submit" value="Return" /> --> - ${form_html} % else: <p>${_("You have selected no items for purchase.")}</p> % endif @@ -60,9 +76,39 @@ }); }); + $('#cart-coupon').click(function(event){ + event.preventDefault(); + var post_url = "${reverse('shoppingcart.views.use_coupon')}"; + $.post(post_url,{ + "coupon_code" : $('#couponCode').val(), + beforeSend: function(xhr, options){ + if($('#couponCode').val() == "") { + showErrorMsgs('Must contain a valid coupon code') + xhr.abort(); + } + } + } + ) + .success(function(data) { + location.reload(true); + }) + .error(function(data,status) { + if(status=="parsererror"){ + location.reload(true); + }else{ + showErrorMsgs(data.responseText) + } + }) + }); + $('#back_input').click(function(){ history.back(); }); + + function showErrorMsgs(msg){ + $(".cart-errors").css('display', 'block'); + $("#cart-error").html(msg); + } }); </script> diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 4ff99a9f7e12582e1547844cc9485cb6313645cf..81da34c0725f197f84300ec42adf2403c21e89a0 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -3,6 +3,7 @@ <%! from django.conf import settings %> <%inherit file="../main.html" /> + <%block name="bodyclass">purchase-receipt</%block> <%block name="pagetitle">${_("Register for [Course Name] | Receipt (Order")} ${order.id})</%block> @@ -37,16 +38,25 @@ <tr> <th class="qty">${_("Qty")}</th> <th class="desc">${_("Description")}</th> + <th class="url">${_("URL")}</th> <th class="u-pr">${_("Unit Price")}</th> <th class="pri">${_("Price")}</th> <th class="curr">${_("Currency")}</th> </tr> % for item in order_items: + + <% course_id = reverse('info', args=[item.course_id.to_deprecated_string()]) %> + <tr class="order-item"> % if item.status == "purchased": <td>${item.qty}</td> <td>${item.line_desc}</td> - <td>${"{0:0.2f}".format(item.unit_cost)}</td> + <td><a href="${course_id}" class="enter-course">${_('View Course')}</a></td> + <td>${"{0:0.2f}".format(item.unit_cost)} + % if item.list_price != None: + <span class="old-price"> ${"{0:0.2f}".format(item.list_price)}</span> + % endif + </td> <td>${"{0:0.2f}".format(item.line_cost)}</td> <td>${item.currency.upper()}</td></tr> % elif item.status == "refunded": diff --git a/lms/urls.py b/lms/urls.py index a112daea5be91dc94cbc353131fbb1b47dbb4a3d..49465863163c13a5b5f603fc867400352f087414 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -283,6 +283,14 @@ if settings.COURSEWARE_ENABLED: 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard"), url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN), include('instructor.views.api_urls')), + url(r'^courses/{}/remove_coupon$'.format(settings.COURSE_ID_PATTERN), + 'instructor.views.coupons.remove_coupon', name="remove_coupon"), + url(r'^courses/{}/add_coupon$'.format(settings.COURSE_ID_PATTERN), + 'instructor.views.coupons.add_coupon', name="add_coupon"), + url(r'^courses/{}/update_coupon$'.format(settings.COURSE_ID_PATTERN), + 'instructor.views.coupons.update_coupon', name="update_coupon"), + url(r'^courses/{}/get_coupon_info$'.format(settings.COURSE_ID_PATTERN), + 'instructor.views.coupons.get_coupon_info', name="get_coupon_info"), # see ENABLE_INSTRUCTOR_LEGACY_DASHBOARD section for legacy dash urls