Separate verification deadline from upgrade deadline

* Add verification deadline model.
* Populate verification deadlines from course modes table.
* Update student dashboard to use verification deadlines.
* Update pay-and-verify view to use verification deadlines.
* Simplify Django admin for course modes and add validation.
* Add verification deadline to Django admin for course modes.
* Add UI for when the upgrade deadline is missed in the pay-and-verify flow.
This commit is contained in:
Will Daly
2015-07-09 13:18:36 -07:00
parent 80cf4d6ecb
commit 25fa2ffc2d
15 changed files with 1181 additions and 195 deletions

View File

@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'HistoricalVerificationDeadline'
db.create_table('verify_student_historicalverificationdeadline', (
('id', self.gf('django.db.models.fields.IntegerField')(db_index=True, blank=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('deadline', self.gf('django.db.models.fields.DateTimeField')()),
(u'history_id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
(u'history_date', self.gf('django.db.models.fields.DateTimeField')()),
(u'history_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name=u'+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])),
(u'history_type', self.gf('django.db.models.fields.CharField')(max_length=1)),
))
db.send_create_signal('verify_student', ['HistoricalVerificationDeadline'])
# Adding model 'VerificationDeadline'
db.create_table('verify_student_verificationdeadline', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
('course_key', self.gf('xmodule_django.models.CourseKeyField')(unique=True, max_length=255, db_index=True)),
('deadline', self.gf('django.db.models.fields.DateTimeField')()),
))
db.send_create_signal('verify_student', ['VerificationDeadline'])
def backwards(self, orm):
# Deleting model 'HistoricalVerificationDeadline'
db.delete_table('verify_student_historicalverificationdeadline')
# Deleting model 'VerificationDeadline'
db.delete_table('verify_student_verificationdeadline')
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'})
},
'verify_student.historicalverificationdeadline': {
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalVerificationDeadline'},
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
},
'verify_student.incoursereverificationconfiguration': {
'Meta': {'ordering': "('-change_date',)", 'object_name': 'InCourseReverificationConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'verify_student.skippedreverification': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'SkippedReverification'},
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'skipped_checkpoint'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'verify_student.softwaresecurephotoverification': {
'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'},
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}),
'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}),
'receipt_id': ('django.db.models.fields.CharField', [], {'default': "'9b14470c-4219-4c69-9a38-d8ff14b60b09'", 'max_length': '255', 'db_index': 'True'}),
'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}),
'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}),
'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}),
'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'verify_student.verificationcheckpoint': {
'Meta': {'unique_together': "(('course_id', 'checkpoint_location'),)", 'object_name': 'VerificationCheckpoint'},
'checkpoint_location': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'photo_verification': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'symmetrical': 'False'})
},
'verify_student.verificationdeadline': {
'Meta': {'object_name': 'VerificationDeadline'},
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
},
'verify_student.verificationstatus': {
'Meta': {'object_name': 'VerificationStatus'},
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'checkpoint_status'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'response': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['verify_student']

View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import DataMigration
from django.db import models
class Migration(DataMigration):
def forwards(self, orm):
"""
This migration populates the "verification deadline" model with
the "expiration datetime" from the course modes table for verified
courses.
In the past, the course modes expiration (really an upgrade deadline)
and the verification deadline were always set to the same value.
With this change, the verification deadline will now be tracked in a separate
model owned by the verify_student app.
"""
# Retrieve all verified course modes (whether they have expired or not)
# Unfortunately, we don't have access to constants from the application here,
# so we hard-code the names of the course modes that require verification.
verified_modes = orm['course_modes.CourseMode'].objects.filter(
mode_slug__in=["verified", "professional"],
expiration_datetime__isnull=False,
)
for mode in verified_modes:
orm.VerificationDeadline.objects.create(
course_key=mode.course_id,
deadline=mode.expiration_datetime,
)
def backwards(self, orm):
"""
Backwards migration deletes all verification deadlines.
"""
orm.VerificationDeadline.objects.all().delete()
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'})
},
'course_modes.coursemode': {
'Meta': {'unique_together': "(('course_id', 'mode_slug', 'currency'),)", 'object_name': 'CourseMode'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'expiration_date': ('django.db.models.fields.DateField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'sku': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
},
'course_modes.coursemodesarchive': {
'Meta': {'object_name': 'CourseModesArchive'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
'expiration_date': ('django.db.models.fields.DateField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
},
'verify_student.historicalverificationdeadline': {
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalVerificationDeadline'},
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
},
'verify_student.incoursereverificationconfiguration': {
'Meta': {'ordering': "('-change_date',)", 'object_name': 'InCourseReverificationConfiguration'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
},
'verify_student.skippedreverification': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'SkippedReverification'},
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'skipped_checkpoint'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'verify_student.softwaresecurephotoverification': {
'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'},
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}),
'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}),
'receipt_id': ('django.db.models.fields.CharField', [], {'default': "'6644e0c2-da9b-49a4-9d0c-c19c596c911e'", 'max_length': '255', 'db_index': 'True'}),
'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}),
'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}),
'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}),
'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'verify_student.verificationcheckpoint': {
'Meta': {'unique_together': "(('course_id', 'checkpoint_location'),)", 'object_name': 'VerificationCheckpoint'},
'checkpoint_location': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'photo_verification': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'symmetrical': 'False'})
},
'verify_student.verificationdeadline': {
'Meta': {'object_name': 'VerificationDeadline'},
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
},
'verify_student.verificationstatus': {
'Meta': {'object_name': 'VerificationStatus'},
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'checkpoint_status'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'response': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'status': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['course_modes', 'verify_student']
symmetrical = True

View File

@@ -24,14 +24,17 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse
from django.core.cache import cache
from django.dispatch import receiver
from django.db import models
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext as _, ugettext_lazy
from boto.s3.connection import S3Connection
from boto.s3.key import Key
from simple_history.models import HistoricalRecords
from config_models.models import ConfigurationModel
from course_modes.models import CourseMode
from model_utils.models import StatusModel
from model_utils.models import StatusModel, TimeStampedModel
from model_utils import Choices
from verify_student.ssencrypt import (
random_aes_key, encrypt_and_encode,
@@ -884,6 +887,119 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return 'ID Verified'
class VerificationDeadline(TimeStampedModel):
"""
Represent a verification deadline for a particular course.
The verification deadline is the datetime after which
users are no longer allowed to submit photos for initial verification
in a course.
Note that this is NOT the same as the "upgrade" deadline, after
which a user is no longer allowed to upgrade to a verified enrollment.
If no verification deadline record exists for a course,
then that course does not have a deadline. This means that users
can submit photos at any time.
"""
course_key = CourseKeyField(
max_length=255,
db_index=True,
unique=True,
help_text=ugettext_lazy(u"The course for which this deadline applies"),
)
deadline = models.DateTimeField(
help_text=ugettext_lazy(
u"The datetime after which users are no longer allowed "
u"to submit photos for verification."
)
)
# Maintain a history of changes to deadlines for auditing purposes
history = HistoricalRecords()
ALL_DEADLINES_CACHE_KEY = "verify_student.all_verification_deadlines"
@classmethod
def set_deadline(cls, course_key, deadline):
"""
Configure the verification deadline for a course.
If `deadline` is `None`, then the course will have no verification
deadline. In this case, users will be able to verify for the course
at any time.
Arguments:
course_key (CourseKey): Identifier for the course.
deadline (datetime or None): The verification deadline.
"""
if deadline is None:
VerificationDeadline.objects.filter(course_key=course_key).delete()
else:
record, created = VerificationDeadline.objects.get_or_create(
course_key=course_key,
defaults={"deadline": deadline}
)
if not created:
record.deadline = deadline
record.save()
@classmethod
def deadlines_for_courses(cls, course_keys):
"""
Retrieve verification deadlines for particular courses.
Arguments:
course_keys (list): List of `CourseKey`s.
Returns:
dict: Map of course keys to datetimes (verification deadlines)
"""
all_deadlines = cache.get(cls.ALL_DEADLINES_CACHE_KEY)
if all_deadlines is None:
all_deadlines = {
deadline.course_key: deadline.deadline
for deadline in VerificationDeadline.objects.all()
}
cache.set(cls.ALL_DEADLINES_CACHE_KEY, all_deadlines)
return {
course_key: all_deadlines[course_key]
for course_key in course_keys
if course_key in all_deadlines
}
@classmethod
def deadline_for_course(cls, course_key):
"""
Retrieve the verification deadline for a particular course.
Arguments:
course_key (CourseKey): The identifier for the course.
Returns:
datetime or None
"""
try:
deadline = cls.objects.get(course_key=course_key)
return deadline.deadline
except cls.DoesNotExist:
return None
@receiver(models.signals.post_save, sender=VerificationDeadline)
@receiver(models.signals.post_delete, sender=VerificationDeadline)
def invalidate_deadline_caches(sender, **kwargs): # pylint: disable=unused-argument
"""Invalidate the cached verification deadline information. """
cache.delete(VerificationDeadline.ALL_DEADLINES_CACHE_KEY)
class VerificationCheckpoint(models.Model):
"""Represents a point at which a user is asked to re-verify his/her
identity.

View File

@@ -7,6 +7,7 @@ import pytz
from django.conf import settings
from django.db.utils import IntegrityError
from django.test import TestCase
from mock import patch
from nose.tools import assert_is_none, assert_equals, assert_raises, assert_true, assert_false # pylint: disable=no-name-in-module
@@ -14,9 +15,13 @@ from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from opaque_keys.edx.keys import CourseKey
from verify_student.models import (
SoftwareSecurePhotoVerification, VerificationException, VerificationCheckpoint, VerificationStatus,
SkippedReverification
SoftwareSecurePhotoVerification,
VerificationException, VerificationCheckpoint,
VerificationStatus, SkippedReverification,
VerificationDeadline
)
FAKE_SETTINGS = {
@@ -763,3 +768,45 @@ class SkippedReverificationTest(ModuleStoreTestCase):
self.assertFalse(
SkippedReverification.check_user_skipped_reverification_exists(course_id=self.course.id, user=user2)
)
class VerificationDeadlineTest(TestCase):
"""
Tests for the VerificationDeadline model.
"""
def test_caching(self):
deadlines = {
CourseKey.from_string("edX/DemoX/Fall"): datetime.now(pytz.UTC),
CourseKey.from_string("edX/DemoX/Spring"): datetime.now(pytz.UTC) + timedelta(days=1)
}
course_keys = deadlines.keys()
# Initially, no deadlines are set
with self.assertNumQueries(1):
all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys)
self.assertEqual(all_deadlines, {})
# Create the deadlines
for course_key, deadline in deadlines.iteritems():
VerificationDeadline.objects.create(
course_key=course_key,
deadline=deadline,
)
# Warm the cache
with self.assertNumQueries(1):
VerificationDeadline.deadlines_for_courses(course_keys)
# Load the deadlines from the cache
with self.assertNumQueries(0):
all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys)
self.assertEqual(all_deadlines, deadlines)
# Delete the deadlines
VerificationDeadline.objects.all().delete()
# Verify that the deadlines are updated correctly
with self.assertNumQueries(1):
all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys)
self.assertEqual(all_deadlines, {})

View File

@@ -43,8 +43,9 @@ from verify_student.views import (
_compose_message_reverification_email
)
from verify_student.models import (
SoftwareSecurePhotoVerification, VerificationCheckpoint,
InCourseReverificationConfiguration, VerificationStatus
VerificationDeadline, SoftwareSecurePhotoVerification,
VerificationCheckpoint, InCourseReverificationConfiguration,
VerificationStatus
)
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
@@ -612,15 +613,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self._assert_contribution_amount(response, "12.34")
def test_verification_deadline(self):
# Set a deadline on the course mode
deadline = datetime(2999, 1, 2, tzinfo=pytz.UTC)
course = self._create_course("verified")
mode = CourseMode.objects.get(
course_id=course.id,
mode_slug="verified"
)
expiration = datetime(2999, 1, 2, tzinfo=pytz.UTC)
mode.expiration_datetime = expiration
mode.save()
# Set a deadline on the course mode AND on the verification deadline model.
# This simulates the common case in which the upgrade deadline (course mode expiration)
# and the verification deadline are the same.
# NOTE: we used to use the course mode expiration datetime for BOTH of these deadlines,
# before the VerificationDeadline model was introduced.
self._set_deadlines(course.id, upgrade_deadline=deadline, verification_deadline=deadline)
# Expect that the expiration date is set
response = self._get_page("verify_student_start_flow", course.id)
@@ -628,14 +629,13 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self.assertEqual(data['verification_deadline'], "Jan 02, 2999 at 00:00 UTC")
def test_course_mode_expired(self):
deadline = datetime(1999, 1, 2, tzinfo=pytz.UTC)
course = self._create_course("verified")
mode = CourseMode.objects.get(
course_id=course.id,
mode_slug="verified"
)
expiration = datetime(1999, 1, 2, tzinfo=pytz.UTC)
mode.expiration_datetime = expiration
mode.save()
# Set the upgrade deadline (course mode expiration) and verification deadline
# to the same value. This used to be the default when we used the expiration datetime
# for BOTH values.
self._set_deadlines(course.id, upgrade_deadline=deadline, verification_deadline=deadline)
# Need to be enrolled
self._enroll(course.id, "verified")
@@ -646,6 +646,66 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self.assertContains(response, "verification deadline")
self.assertContains(response, "Jan 02, 1999 at 00:00 UTC")
@ddt.data(datetime(2999, 1, 2, tzinfo=pytz.UTC), None)
def test_course_mode_expired_verification_deadline_in_future(self, verification_deadline):
course = self._create_course("verified")
# Set the upgrade deadline in the past, but the verification
# deadline in the future.
self._set_deadlines(
course.id,
upgrade_deadline=datetime(1999, 1, 2, tzinfo=pytz.UTC),
verification_deadline=verification_deadline,
)
# Try to pay or upgrade.
# We should get an error message since the deadline has passed.
for page_name in ["verify_student_start_flow", "verify_student_upgrade_and_verify"]:
response = self._get_page(page_name, course.id)
self.assertContains(response, "Upgrade Deadline Has Passed")
# Simulate paying for the course and enrolling
self._enroll(course.id, "verified")
# Enter the verification part of the flow
# Expect that we are able to verify
response = self._get_page("verify_student_verify_now", course.id)
self.assertNotContains(response, "Verification is no longer available")
data = self._get_page_data(response)
self.assertEqual(data['message_key'], PayAndVerifyView.VERIFY_NOW_MSG)
# Check that the verification deadline (rather than the upgrade deadline) is displayed
if verification_deadline is not None:
self.assertEqual(data["verification_deadline"], "Jan 02, 2999 at 00:00 UTC")
else:
self.assertEqual(data["verification_deadline"], "")
def test_course_mode_not_expired_verification_deadline_passed(self):
course = self._create_course("verified")
# Set the upgrade deadline in the future
# and the verification deadline in the past
# We try not to discourage this with validation rules,
# since it's a bad user experience
# to purchase a verified track and then not be able to verify,
# but if it happens we need to handle it gracefully.
self._set_deadlines(
course.id,
upgrade_deadline=datetime(2999, 1, 2, tzinfo=pytz.UTC),
verification_deadline=datetime(1999, 1, 2, tzinfo=pytz.UTC),
)
# Enroll as verified (simulate purchasing the verified enrollment)
self._enroll(course.id, "verified")
# Even though the upgrade deadline is in the future,
# the verification deadline has passed, so we should see an error
# message when we go to verify.
response = self._get_page("verify_student_verify_now", course.id)
self.assertContains(response, "verification deadline")
self.assertContains(response, "Jan 02, 1999 at 00:00 UTC")
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def test_embargo_restrict(self):
course = self._create_course("verified")
@@ -716,6 +776,30 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
attempt.created_at = datetime.now(pytz.UTC) - timedelta(days=(days_good_for + 1))
attempt.save()
def _set_deadlines(self, course_key, upgrade_deadline=None, verification_deadline=None):
"""
Set the upgrade and verification deadlines.
Arguments:
course_key (CourseKey): Identifier for the course.
Keyword Arguments:
upgrade_deadline (datetime): Datetime after which a user cannot
upgrade to a verified mode.
verification_deadline (datetime): Datetime after which a user cannot
submit an initial verification attempt.
"""
# Set the course mode expiration (same as the "upgrade" deadline)
mode = CourseMode.objects.get(course_id=course_key, mode_slug="verified")
mode.expiration_datetime = upgrade_deadline
mode.save()
# Set the verification deadline
VerificationDeadline.set_deadline(course_key, verification_deadline)
def _set_contribution(self, amount, course_id):
"""Set the contribution amount pre-filled in a session var. """
session = self.client.session
@@ -785,6 +869,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
"""Retrieve the data attributes rendered on the page. """
soup = BeautifulSoup(response.content)
pay_and_verify_div = soup.find(id="pay-and-verify-container")
self.assertIsNot(
pay_and_verify_div, None,
msg=(
"Could not load pay and verify flow data. "
"Maybe this isn't the pay and verify page?"
)
)
return {
'full_name': pay_and_verify_div['data-full-name'],
'course_key': pay_and_verify_div['data-course-key'],

View File

@@ -47,6 +47,7 @@ from shoppingcart.processors import (
)
from verify_student.ssencrypt import has_valid_signature
from verify_student.models import (
VerificationDeadline,
SoftwareSecurePhotoVerification,
VerificationCheckpoint,
VerificationStatus,
@@ -194,6 +195,10 @@ class PayAndVerifyView(View):
FACE_PHOTO_STEP: [WEBCAM_REQ],
}
# Deadline types
VERIFICATION_DEADLINE = "verification"
UPGRADE_DEADLINE = "upgrade"
@method_decorator(login_required)
def get(
self, request, course_id,
@@ -201,7 +206,8 @@ class PayAndVerifyView(View):
current_step=None,
message=FIRST_TIME_VERIFY_MSG
):
"""Render the pay/verify requirements page.
"""
Render the payment and verification flow.
Arguments:
request (HttpRequest): The request object.
@@ -244,28 +250,45 @@ class PayAndVerifyView(View):
if redirect_url:
return redirect(redirect_url)
expired_verified_course_mode, unexpired_paid_course_mode = self._get_expired_verified_and_paid_mode(course_key)
# If the verification deadline has passed
# then show the user a message that he/she can't verify.
#
# We're making the assumptions (enforced in Django admin) that:
#
# 1) Only verified modes have verification deadlines.
#
# 2) If set, verification deadlines are always AFTER upgrade deadlines, because why would you
# let someone upgrade into a verified track if they can't complete verification?
#
verification_deadline = VerificationDeadline.deadline_for_course(course.id)
response = self._response_if_deadline_passed(course, self.VERIFICATION_DEADLINE, verification_deadline)
if response is not None:
log.info(u"Verification deadline for '%s' has passed.", course.id)
return response
# Check that the course has an unexpired paid mode
if unexpired_paid_course_mode is not None:
if CourseMode.is_verified_mode(unexpired_paid_course_mode):
# Retrieve the relevant course mode for the payment/verification flow.
#
# WARNING: this is technical debt! A much better way to do this would be to
# separate out the payment flow and use the product SKU to figure out what
# the user is trying to purchase.
#
# Nonethless, for the time being we continue to make the really ugly assumption
# that at some point there was a paid course mode we can query for the price.
relevant_course_mode = self._get_paid_mode(course_key)
# If we can find a relevant course mode, then log that we're entering the flow
# Otherwise, this course does not support payment/verification, so respond with a 404.
if relevant_course_mode is not None:
if CourseMode.is_verified_mode(relevant_course_mode):
log.info(
u"Entering verified workflow for user '%s', course '%s', with current step '%s'.",
u"Entering payment and verification flow for user '%s', course '%s', with current step '%s'.",
request.user.id, course_id, current_step
)
elif expired_verified_course_mode is not None:
# Check if there is an *expired* verified course mode;
# if so, we should show a message explaining that the verification
# deadline has passed.
log.info(u"Verification deadline for '%s' has passed.", course_id)
context = {
'course': course,
'deadline': (
get_default_time_display(expired_verified_course_mode.expiration_datetime)
if expired_verified_course_mode.expiration_datetime else ""
else:
log.info(
u"Entering payment flow for user '%s', course '%s', with current step '%s'",
request.user.id, course_id, current_step
)
}
return render_to_response("verify_student/missed_verification_deadline.html", context)
else:
# Otherwise, there has never been a verified/paid mode,
# so return a page not found response.
@@ -275,14 +298,32 @@ class PayAndVerifyView(View):
)
raise Http404
# If the user is trying to *pay* and the upgrade deadline has passed,
# then they shouldn't be able to enter the flow.
#
# NOTE: This should match the availability dates used by the E-Commerce service
# to determine whether a user can purchase a product. The idea is that if the service
# won't fulfill the order, we shouldn't even let the user get into the payment flow.
#
user_is_trying_to_pay = message in [self.FIRST_TIME_VERIFY_MSG, self.UPGRADE_MSG]
if user_is_trying_to_pay:
upgrade_deadline = relevant_course_mode.expiration_datetime
response = self._response_if_deadline_passed(course, self.UPGRADE_DEADLINE, upgrade_deadline)
if response is not None:
log.info(u"Upgrade deadline for '%s' has passed.", course.id)
return response
# Check whether the user has verified, paid, and enrolled.
# A user is considered "paid" if he or she has an enrollment
# with a paid course mode (such as "verified").
# For this reason, every paid user is enrolled, but not
# every enrolled user is paid.
# If the course mode is not verified(i.e only paid) then already_verified is always True
already_verified = self._check_already_verified(request.user) \
if CourseMode.is_verified_mode(unexpired_paid_course_mode) else True
already_verified = (
self._check_already_verified(request.user)
if CourseMode.is_verified_mode(relevant_course_mode)
else True
)
already_paid, is_enrolled = self._check_enrollment(request.user, course_key)
# Redirect the user to a more appropriate page if the
@@ -302,7 +343,7 @@ class PayAndVerifyView(View):
always_show_payment,
already_verified,
already_paid,
unexpired_paid_course_mode
relevant_course_mode
)
requirements = self._requirements(display_steps, request.user.is_active)
@@ -346,7 +387,7 @@ class PayAndVerifyView(View):
verification_good_until = self._verification_valid_until(request.user)
# get available payment processors
if unexpired_paid_course_mode.sku:
if relevant_course_mode.sku:
# transaction will be conducted via ecommerce service
processors = ecommerce_api_client(request.user).payment.processors.get()
else:
@@ -358,7 +399,7 @@ class PayAndVerifyView(View):
'contribution_amount': contribution_amount,
'course': course,
'course_key': unicode(course_key),
'course_mode': unexpired_paid_course_mode,
'course_mode': relevant_course_mode,
'courseware_url': courseware_url,
'current_step': current_step,
'disable_courseware_js': True,
@@ -370,8 +411,8 @@ class PayAndVerifyView(View):
'requirements': requirements,
'user_full_name': full_name,
'verification_deadline': (
get_default_time_display(unexpired_paid_course_mode.expiration_datetime)
if unexpired_paid_course_mode.expiration_datetime else ""
get_default_time_display(verification_deadline)
if verification_deadline else ""
),
'already_verified': already_verified,
'verification_good_until': verification_good_until,
@@ -449,41 +490,35 @@ class PayAndVerifyView(View):
if url is not None:
return redirect(url)
def _get_expired_verified_and_paid_mode(self, course_key): # pylint: disable=invalid-name
"""Retrieve expired verified mode and unexpired paid mode(with min_price>0) for a course.
def _get_paid_mode(self, course_key):
"""
Retrieve the paid course mode for a course.
The returned course mode may or may not be expired.
Unexpired modes are preferred to expired modes.
Arguments:
course_key (CourseKey): The location of the course.
Returns:
Tuple of `(expired_verified_mode, unexpired_paid_mode)`. If provided,
`expired_verified_mode` is an *expired* verified mode for the course.
If provided, `unexpired_paid_mode` is an *unexpired* paid(with min_price>0)
mode for the course. Either of these may be None.
CourseMode tuple
"""
# Retrieve all the modes at once to reduce the number of database queries
all_modes, unexpired_modes = CourseMode.all_and_unexpired_modes_for_courses([course_key])
# Unexpired paid modes
unexpired_paid_modes = [mode for mode in unexpired_modes[course_key] if mode.min_price]
if len(unexpired_paid_modes) > 1:
# There is more than one paid mode defined,
# so choose the first one.
log.warn(
u"More than one paid modes are defined for course '%s' choosing the first one %s",
course_key, unexpired_paid_modes[0]
)
unexpired_paid_mode = unexpired_paid_modes[0] if unexpired_paid_modes else None
# Retrieve the first unexpired, paid mode, if there is one
for mode in unexpired_modes[course_key]:
if mode.min_price > 0:
return mode
# Find an unexpired verified mode
verified_mode = CourseMode.verified_mode_for_course(course_key, modes=unexpired_modes[course_key])
expired_verified_mode = None
# Otherwise, find the first expired mode
for mode in all_modes[course_key]:
if mode.min_price > 0:
return mode
if verified_mode is None:
expired_verified_mode = CourseMode.verified_mode_for_course(course_key, modes=all_modes[course_key])
return (expired_verified_mode, unexpired_paid_mode)
# Otherwise, return None and so the view knows to respond with a 404.
return None
def _display_steps(self, always_show_payment, already_verified, already_paid, course_mode):
"""Determine which steps to display to the user.
@@ -610,12 +645,43 @@ class PayAndVerifyView(View):
has_paid = False
if enrollment_mode is not None and is_active:
all_modes = CourseMode.modes_for_course_dict(course_key)
all_modes = CourseMode.modes_for_course_dict(course_key, include_expired=True)
course_mode = all_modes.get(enrollment_mode)
has_paid = (course_mode and course_mode.min_price > 0)
return (has_paid, bool(is_active))
def _response_if_deadline_passed(self, course, deadline_name, deadline_datetime):
"""
Respond with some error messaging if the deadline has passed.
Arguments:
course (Course): The course the user is trying to enroll in.
deadline_name (str): One of the deadline constants.
deadline_datetime (datetime): The deadline.
Returns: HttpResponse or None
"""
if deadline_name not in [self.VERIFICATION_DEADLINE, self.UPGRADE_DEADLINE]:
log.error("Invalid deadline name %s. Skipping check for whether the deadline passed.", deadline_name)
return None
deadline_passed = (
deadline_datetime is not None and
deadline_datetime < datetime.datetime.now(UTC)
)
if deadline_passed:
context = {
'course': course,
'deadline_name': deadline_name,
'deadline': (
get_default_time_display(deadline_datetime)
if deadline_datetime else ""
)
}
return render_to_response("verify_student/missed_deadline.html", context)
def checkout_with_ecommerce_service(user, course_key, course_mode, processor): # pylint: disable=invalid-name
""" Create a new basket and trigger immediate checkout, using the E-Commerce API. """

View File

@@ -0,0 +1,29 @@
<%!
from django.utils.translation import ugettext as _
from verify_student.views import PayAndVerifyView
%>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="pagetitle">
% if deadline_name == PayAndVerifyView.VERIFICATION_DEADLINE:
${_("Verification Deadline Has Passed")}
% elif deadline_name == PayAndVerifyView.UPGRADE_DEADLINE:
${_("Upgrade Deadline Has Passed")}
% endif
</%block>
<%block name="content">
<section class="outside-app">
<p>
% if deadline_name == PayAndVerifyView.VERIFICATION_DEADLINE:
${_(u"The verification deadline for {course_name} was {date}. Verification is no longer available.").format(
course_name=course.display_name, date=deadline)}
% elif deadline_name == PayAndVerifyView.UPGRADE_DEADLINE:
${_(u"The deadline to upgrade to a verified certificate for this course has passed. You can still earn an honor code certificate.")}
% endif
</p>
</section>
</%block>

View File

@@ -1,18 +0,0 @@
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="pagetitle">${_("Verification Deadline Has Passed")}</%block>
<%block name="content">
<section class="outside-app">
<p>${_(
u"The verification deadline for {course_name} was {date}. "
u"Verification is no longer available."
).format(
course_name=course.display_name,
date=deadline
)}</p>
</section>
</%block>