From 9530021b3f01699c224ae94d6dc7087a2afb3a8c Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 25 Feb 2015 13:47:03 -0500 Subject: [PATCH] ECOM-1139: Example certificates. Add the ability to generate "example" certificates to test that certificate generation is working correctly for a course. Add the ability to enable/disable self-generated certificates on a per-course basis. --- lms/djangoapps/certificates/api.py | 138 +++++++- .../0018_add_example_cert_models.py | 159 +++++++++ lms/djangoapps/certificates/models.py | 327 +++++++++++++++++- lms/djangoapps/certificates/queue.py | 169 +++++++-- lms/djangoapps/certificates/tests/test_api.py | 257 ++++++++++++++ .../certificates/tests/test_models.py | 73 ++++ .../certificates/tests/test_queue.py | 97 ++++++ .../certificates/tests/test_views.py | 153 ++++++++ .../certificates/tests/tests_api.py | 141 -------- lms/djangoapps/certificates/views.py | 109 +++++- lms/urls.py | 2 + 11 files changed, 1445 insertions(+), 180 deletions(-) create mode 100644 lms/djangoapps/certificates/migrations/0018_add_example_cert_models.py create mode 100644 lms/djangoapps/certificates/tests/test_api.py create mode 100644 lms/djangoapps/certificates/tests/test_models.py create mode 100644 lms/djangoapps/certificates/tests/test_queue.py create mode 100644 lms/djangoapps/certificates/tests/test_views.py delete mode 100644 lms/djangoapps/certificates/tests/tests_api.py diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index c2c2191b3b..ac99a8f3c4 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -1,11 +1,20 @@ -""" -Certificates API -""" +"""Certificates API +This is a Python API for generating certificates asynchronously. +Other Django apps should use the API functions defined in this module +rather than importing Django models directly. +""" import logging -from certificates.models import CertificateStatuses as cert_status, certificate_status_for_student +from certificates.models import ( + CertificateStatuses as cert_status, + certificate_status_for_student, + CertificateGenerationCourseSetting, + CertificateGenerationConfiguration, + ExampleCertificateSet +) from certificates.queue import XQueueCertInterface + log = logging.getLogger("edx.certificate") @@ -63,3 +72,124 @@ def certificate_downloadable_status(student, course_key): response_data['download_url'] = current_status['download_url'] return response_data + + +def set_cert_generation_enabled(course_key, is_enabled): + """Enable or disable self-generated certificates for a course. + + There are two "switches" that control whether self-generated certificates + are enabled for a course: + + 1) Whether the self-generated certificates feature is enabled. + 2) Whether self-generated certificates have been enabled for this particular course. + + The second flag should be enabled *only* when someone has successfully + generated example certificates for the course. This helps avoid + configuration errors (for example, not having a template configured + for the course installed on the workers). The UI for the instructor + dashboard enforces this constraint. + + Arguments: + course_key (CourseKey): The course identifier. + + Keyword Arguments: + is_enabled (boolean): If provided, enable/disable self-generated + certificates for this course. + + """ + CertificateGenerationCourseSetting.set_enabled_for_course(course_key, is_enabled) + + if is_enabled: + log.info(u"Enabled self-generated certificates for course '%s'.", unicode(course_key)) + else: + log.info(u"Disabled self-generated certificates for course '%s'.", unicode(course_key)) + + +def cert_generation_enabled(course_key): + """Check whether certificate generation is enabled for a course. + + There are two "switches" that control whether self-generated certificates + are enabled for a course: + + 1) Whether the self-generated certificates feature is enabled. + 2) Whether self-generated certificates have been enabled for this particular course. + + Certificates are enabled for a course only when both switches + are set to True. + + Arguments: + course_key (CourseKey): The course identifier. + + Returns: + boolean: Whether self-generated certificates are enabled + for the course. + + """ + return ( + CertificateGenerationConfiguration.current().enabled and + CertificateGenerationCourseSetting.is_enabled_for_course(course_key) + ) + + +def generate_example_certificates(course_key): + """Generate example certificates for a course. + + Example certificates are used to validate that certificates + are configured correctly for the course. Staff members can + view the example certificates before enabling + the self-generated certificates button for students. + + Several example certificates may be generated for a course. + For example, if a course offers both verified and honor certificates, + examples of both types of certificate will be generated. + + If an error occurs while starting the certificate generation + job, the errors will be recorded in the database and + can be retrieved using `example_certificate_status()`. + + Arguments: + course_key (CourseKey): The course identifier. + + Returns: + None + + """ + xqueue = XQueueCertInterface() + for cert in ExampleCertificateSet.create_example_set(course_key): + xqueue.add_example_cert(cert) + + log.info(u"Started generated example certificates for course '%s'.", course_key) + + +def example_certificates_status(course_key): + """Check the status of example certificates for a course. + + This will check the *latest* example certificate task. + This is generally what we care about in terms of enabling/disabling + self-generated certificates for a course. + + Arguments: + course_key (CourseKey): The course identifier. + + Returns: + list + + Example Usage: + + >>> from certificates import api as certs_api + >>> certs_api.example_certificate_status(course_key) + [ + { + 'description': 'honor', + 'status': 'success', + 'download_url': 'http://www.example.com/abcd/honor_cert.pdf' + }, + { + 'description': 'verified', + 'status': 'error', + 'error_reason': 'No template found!' + } + ] + + """ + return ExampleCertificateSet.latest_status(course_key) diff --git a/lms/djangoapps/certificates/migrations/0018_add_example_cert_models.py b/lms/djangoapps/certificates/migrations/0018_add_example_cert_models.py new file mode 100644 index 0000000000..372f45fd16 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0018_add_example_cert_models.py @@ -0,0 +1,159 @@ +# -*- 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 'CertificateGenerationCourseSetting' + db.create_table('certificates_certificategenerationcoursesetting', ( + ('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')(max_length=255, db_index=True)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('certificates', ['CertificateGenerationCourseSetting']) + + # Adding model 'ExampleCertificate' + db.create_table('certificates_examplecertificate', ( + ('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)), + ('example_cert_set', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['certificates.ExampleCertificateSet'])), + ('description', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('uuid', self.gf('django.db.models.fields.CharField')(default='2017ea093523484cb355fac9c3e7a22b', unique=True, max_length=255, db_index=True)), + ('access_key', self.gf('django.db.models.fields.CharField')(default='8dc842be46484291a65b9ea34c3a8af8', max_length=255, db_index=True)), + ('full_name', self.gf('django.db.models.fields.CharField')(default=u'John Do\xeb', max_length=255)), + ('template', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('status', self.gf('django.db.models.fields.CharField')(default='started', max_length=255)), + ('error_reason', self.gf('django.db.models.fields.TextField')(default=None, null=True)), + ('download_url', self.gf('django.db.models.fields.CharField')(default=None, max_length=255, null=True)), + )) + db.create_index('certificates_examplecertificate', ['uuid', 'access_key']) + db.send_create_signal('certificates', ['ExampleCertificate']) + + # Adding model 'ExampleCertificateSet' + db.create_table('certificates_examplecertificateset', ( + ('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')(max_length=255, db_index=True)), + )) + db.send_create_signal('certificates', ['ExampleCertificateSet']) + + def backwards(self, orm): + # Deleting model 'CertificateGenerationCourseSetting' + db.delete_table('certificates_certificategenerationcoursesetting') + + # Deleting model 'ExampleCertificate' + db.delete_table('certificates_examplecertificate') + + # Deleting model 'ExampleCertificateSet' + db.delete_table('certificates_examplecertificateset') + + 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'}) + }, + 'certificates.certificategenerationconfiguration': { + 'Meta': {'object_name': 'CertificateGenerationConfiguration'}, + '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'}) + }, + 'certificates.certificategenerationcoursesetting': { + 'Meta': {'object_name': 'CertificateGenerationCourseSetting'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', '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']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.examplecertificate': { + 'Meta': {'object_name': 'ExampleCertificate'}, + 'access_key': ('django.db.models.fields.CharField', [], {'default': "'616e8368b9b2458b8ef8217713275322'", 'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + 'error_reason': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + 'example_cert_set': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['certificates.ExampleCertificateSet']"}), + 'full_name': ('django.db.models.fields.CharField', [], {'default': "u'John Do\\xeb'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'started'", 'max_length': '255'}), + 'template': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'uuid': ('django.db.models.fields.CharField', [], {'default': "'f38e7389280d4776907ebf96ac728bac'", 'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'certificates.examplecertificateset': { + 'Meta': {'object_name': 'ExampleCertificateSet'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['certificates'] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 0783bb3bb6..31202a558b 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Certificates are created for a student and an offering of a course. @@ -44,17 +45,21 @@ Eligibility: then the student will be issued a certificate regardless of his grade, unless he has allow_certificate set to False. """ +from datetime import datetime +import uuid from django.contrib.auth.models import User -from django.db import models +from django.db import models, transaction from django.db.models.signals import post_save from django.dispatch import receiver from django.conf import settings -from datetime import datetime +from django.utils.translation import ugettext_lazy from model_utils import Choices +from model_utils.models import TimeStampedModel from config_models.models import ConfigurationModel from xmodule_django.models import CourseKeyField, NoneToEmptyManager from util.milestones_helpers import fulfill_course_milestone +from course_modes.models import CourseMode class CertificateStatuses(object): @@ -179,6 +184,322 @@ def certificate_status_for_student(student, course_id): return {'status': CertificateStatuses.unavailable, 'mode': GeneratedCertificate.MODES.honor} +class ExampleCertificateSet(TimeStampedModel): + """A set of example certificates. + + Example certificates are used to verify that certificate + generation is working for a particular course. + + A particular course may have several kinds of certificates + (e.g. honor and verified), in which case we generate + multiple example certificates for the course. + + """ + course_key = CourseKeyField(max_length=255, db_index=True) + + class Meta: # pylint: disable=missing-docstring, old-style-class + get_latest_by = 'created' + + @classmethod + @transaction.commit_on_success + def create_example_set(cls, course_key): + """Create a set of example certificates for a course. + + Arguments: + course_key (CourseKey) + + Returns: + ExampleCertificateSet + + """ + cert_set = cls.objects.create(course_key=course_key) + + ExampleCertificate.objects.bulk_create([ + ExampleCertificate( + example_cert_set=cert_set, + description=mode.slug, + template=cls._template_for_mode(mode.slug, course_key) + ) + for mode in CourseMode.modes_for_course(course_key) + ]) + + return cert_set + + @classmethod + def latest_status(cls, course_key): + """Summarize the latest status of example certificates for a course. + + Arguments: + course_key (CourseKey) + + Returns: + list: List of status dictionaries. If no example certificates + have been started yet, returns None. + + """ + try: + latest = cls.objects.filter(course_key=course_key).latest() + except cls.DoesNotExist: + return None + + queryset = ExampleCertificate.objects.filter(example_cert_set=latest).order_by('-created') + return [cert.status_dict for cert in queryset] + + def __iter__(self): + """Iterate through example certificates in the set. + + Yields: + ExampleCertificate + + """ + queryset = (ExampleCertificate.objects).select_related('example_cert_set').filter(example_cert_set=self) + for cert in queryset: + yield cert + + @staticmethod + def _template_for_mode(mode_slug, course_key): + """Calculate the template PDF based on the course mode. """ + return ( + u"certificate-template-{key.org}-{key.course}-verified.pdf".format(key=course_key) + if mode_slug == 'verified' + else u"certificate-template-{key.org}-{key.course}.pdf".format(key=course_key) + ) + + +def _make_uuid(): + """Return a 32-character UUID. """ + return uuid.uuid4().hex + + +class ExampleCertificate(TimeStampedModel): + """Example certificate. + + Example certificates are used to verify that certificate + generation is working for a particular course. + + An example certificate is similar to an ordinary certificate, + except that: + + 1) Example certificates are not associated with a particular user, + and are never displayed to students. + + 2) We store the "inputs" for generating the example certificate + to make it easier to debug when certificate generation fails. + + 3) We use dummy values. + + """ + # Statuses + STATUS_STARTED = 'started' + STATUS_SUCCESS = 'success' + STATUS_ERROR = 'error' + + # Dummy full name for the generated certificate + EXAMPLE_FULL_NAME = u'John Doë' + + example_cert_set = models.ForeignKey(ExampleCertificateSet) + + description = models.CharField( + max_length=255, + help_text=ugettext_lazy( + u"A human-readable description of the example certificate. " + u"For example, 'verified' or 'honor' to differentiate between " + u"two types of certificates." + ) + ) + + # Inputs to certificate generation + # We store this for auditing purposes if certificate + # generation fails. + uuid = models.CharField( + max_length=255, + default=_make_uuid, + db_index=True, + unique=True, + help_text=ugettext_lazy( + u"A unique identifier for the example certificate. " + u"This is used when we receive a response from the queue " + u"to determine which example certificate was processed." + ) + ) + + access_key = models.CharField( + max_length=255, + default=_make_uuid, + db_index=True, + help_text=ugettext_lazy( + u"An access key for the example certificate. " + u"This is used when we receive a response from the queue " + u"to validate that the sender is the same entity we asked " + u"to generate the certificate." + ) + ) + + full_name = models.CharField( + max_length=255, + default=EXAMPLE_FULL_NAME, + help_text=ugettext_lazy(u"The full name that will appear on the certificate.") + ) + + template = models.CharField( + max_length=255, + help_text=ugettext_lazy(u"The template file to use when generating the certificate.") + ) + + # Outputs from certificate generation + status = models.CharField( + max_length=255, + default=STATUS_STARTED, + choices=( + (STATUS_STARTED, 'Started'), + (STATUS_SUCCESS, 'Success'), + (STATUS_ERROR, 'Error') + ), + help_text=ugettext_lazy(u"The status of the example certificate.") + ) + + error_reason = models.TextField( + null=True, + default=None, + help_text=ugettext_lazy(u"The reason an error occurred during certificate generation.") + ) + + download_url = models.CharField( + max_length=255, + null=True, + default=None, + help_text=ugettext_lazy(u"The download URL for the generated certificate.") + ) + + def update_status(self, status, error_reason=None, download_url=None): + """Update the status of the example certificate. + + This will usually be called either: + 1) When an error occurs adding the certificate to the queue. + 2) When we receieve a response from the queue (either error or success). + + If an error occurs, we store the error message; + if certificate generation is successful, we store the URL + for the generated certificate. + + Arguments: + status (str): Either `STATUS_SUCCESS` or `STATUS_ERROR` + + Keyword Arguments: + error_reason (unicode): A description of the error that occurred. + download_url (unicode): The URL for the generated certificate. + + Raises: + ValueError: The status is not a valid value. + + """ + if status not in [self.STATUS_SUCCESS, self.STATUS_ERROR]: + msg = u"Invalid status: must be either '{success}' or '{error}'.".format( + success=self.STATUS_SUCCESS, + error=self.STATUS_ERROR + ) + raise ValueError(msg) + + self.status = status + + if status == self.STATUS_ERROR and error_reason: + self.error_reason = error_reason + + if status == self.STATUS_SUCCESS and download_url: + self.download_url = download_url + + self.save() + + @property + def status_dict(self): + """Summarize the status of the example certificate. + + Returns: + dict + + """ + result = { + 'description': self.description, + 'status': self.status, + } + + if self.error_reason: + result['error_reason'] = self.error_reason + + if self.download_url: + result['download_url'] = self.download_url + + return result + + @property + def course_key(self): + """The course key associated with the example certificate. """ + return self.example_cert_set.course_key + + +class CertificateGenerationCourseSetting(TimeStampedModel): + """Enable or disable certificate generation for a particular course. + + This controls whether students are allowed to "self-generate" + certificates for a course. It does NOT prevent us from + batch-generating certificates for a course using management + commands. + + In general, we should only enable self-generated certificates + for a course once we successfully generate example certificates + for the course. This is enforced in the UI layer, but + not in the data layer. + + """ + course_key = CourseKeyField(max_length=255, db_index=True) + enabled = models.BooleanField(default=False) + + class Meta: # pylint: disable=missing-docstring, old-style-class + get_latest_by = 'created' + + @classmethod + def is_enabled_for_course(cls, course_key): + """Check whether self-generated certificates are enabled for a course. + + Arguments: + course_key (CourseKey): The identifier for the course. + + Returns: + boolean + + """ + try: + latest = cls.objects.filter(course_key=course_key).latest() + except cls.DoesNotExist: + return False + else: + return latest.enabled + + @classmethod + def set_enabled_for_course(cls, course_key, is_enabled): + """Enable or disable self-generated certificates for a course. + + Arguments: + course_key (CourseKey): The identifier for the course. + is_enabled (boolean): Whether to enable or disable self-generated certificates. + + """ + CertificateGenerationCourseSetting.objects.create( + course_key=course_key, + enabled=is_enabled + ) + + class CertificateGenerationConfiguration(ConfigurationModel): - """Configure certificate generation.""" + """Configure certificate generation. + + Enable or disable the self-generated certificates feature. + When this flag is disabled, the "generate certificate" button + will be hidden on the progress page. + + When the feature is enabled, the "generate certificate" button + will appear for courses that have enabled self-generated + certificates. + + """ pass diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 782cb3ac7e..661c6b42a1 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -1,27 +1,52 @@ -from certificates.models import GeneratedCertificate -from certificates.models import certificate_status_for_student -from certificates.models import CertificateStatuses as status -from certificates.models import CertificateWhitelist - -from courseware import grades, courses -from django.test.client import RequestFactory -from capa.xqueue_interface import XQueueInterface -from capa.xqueue_interface import make_xheader, make_hashkey -from django.conf import settings -from requests.auth import HTTPBasicAuth -from student.models import UserProfile, CourseEnrollment -from verify_student.models import SoftwareSecurePhotoVerification - +"""Interface for adding certificate generation tasks to the XQueue. """ import json import random import logging import lxml.html -from lxml.etree import XMLSyntaxError, ParserError +from lxml.etree import XMLSyntaxError, ParserError # pylint:disable=no-name-in-module + +from django.test.client import RequestFactory +from django.conf import settings +from django.core.urlresolvers import reverse +from requests.auth import HTTPBasicAuth + +from courseware import grades +from xmodule.modulestore.django import modulestore +from capa.xqueue_interface import XQueueInterface +from capa.xqueue_interface import make_xheader, make_hashkey +from student.models import UserProfile, CourseEnrollment +from verify_student.models import SoftwareSecurePhotoVerification + +from certificates.models import ( + GeneratedCertificate, + certificate_status_for_student, + CertificateStatuses as status, + CertificateWhitelist, + ExampleCertificate +) LOGGER = logging.getLogger(__name__) +class XQueueAddToQueueError(Exception): + """An error occurred when adding a certificate task to the queue. """ + + def __init__(self, error_code, error_msg): + self.error_code = error_code + self.error_msg = error_msg + super(XQueueAddToQueueError, self).__init__(unicode(self)) + + def __unicode__(self): + return ( + u"Could not add certificate to the XQueue. " + u"The error code was '{code}' and the message was '{msg}'." + ).format( + code=self.error_code, + msg=self.error_msg + ) + + class XQueueCertInterface(object): """ XQueueCertificateInterface provides an @@ -205,7 +230,7 @@ class XQueueCertInterface(object): # re-use the course passed in optionally so we don't have to re-fetch everything # for every student if course is None: - course = courses.get_course_by_id(course_id) + course = modulestore().get_course(course_id, depth=0) profile = UserProfile.objects.get(user=student) profile_name = profile.name @@ -213,7 +238,7 @@ class XQueueCertInterface(object): self.request.user = student self.request.session = {} - course_name = course.display_name or course_id.to_deprecated_string() + course_name = course.display_name or unicode(course_id) is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists() grade = grades.grade(student, self.request, course) enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(student, course_id) @@ -297,7 +322,7 @@ class XQueueCertInterface(object): contents = { 'action': 'create', 'username': student.username, - 'course_id': course_id.to_deprecated_string(), + 'course_id': unicode(course_id), 'course_name': course_name, 'name': profile_name, 'grade': grade_contents, @@ -337,20 +362,106 @@ class XQueueCertInterface(object): return new_status - def _send_to_xqueue(self, contents, key): - """Create a new task on the XQueue. """ + def add_example_cert(self, example_cert): + """Add a task to create an example certificate. - if self.use_https: - proto = "https" - else: - proto = "http" + Unlike other certificates, an example certificate is + not associated with any particular user and is never + shown to students. - xheader = make_xheader( - '{0}://{1}/update_certificate?{2}'.format( - proto, settings.SITE_NAME, key), key, settings.CERT_QUEUE) + If an error occurs when adding the example certificate + to the queue, the example certificate status + will be set to "error". + + Arguments: + example_cert (ExampleCertificate) + + """ + contents = { + 'action': 'create', + 'course_id': unicode(example_cert.course_key), + 'name': example_cert.full_name, + 'template_pdf': example_cert.template, + + # Example certificates are not associated with a particular user. + # However, we still need to find the example certificate when + # we receive a response from the queue. For this reason, + # we use the example certificate's unique identifier as a username. + # Note that the username is *not* displayed on the certificate; + # it is used only to identify the certificate task in the queue. + 'username': example_cert.uuid, + + # We send this extra parameter to differentiate + # example certificates from other certificates. + # This is not used by the certificates workers or XQueue. + 'example_certificate': True, + } + + # The callback for example certificates is different than the callback + # for other certificates. Although both tasks use the same queue, + # we can distinguish whether the certificate was an example cert based + # on which end-point XQueue uses once the task completes. + callback_url_path = reverse('certificates.views.update_example_certificate') + + try: + self._send_to_xqueue( + contents, + example_cert.access_key, + task_identifier=example_cert.uuid, + callback_url_path=callback_url_path + ) + except XQueueAddToQueueError as exc: + example_cert.update_status( + ExampleCertificate.STATUS_ERROR, + error_reason=unicode(exc) + ) + + def _send_to_xqueue(self, contents, key, task_identifier=None, callback_url_path='update_certificate'): + """Create a new task on the XQueue. + + Arguments: + contents (dict): The contents of the XQueue task. + key (str): An access key for the task. This will be sent + to the callback end-point once the task completes, + so that we can validate that the sender is the same + entity that received the task. + + Keyword Arguments: + callback_url_path (str): The path of the callback URL. + If not provided, use the default end-point for student-generated + certificates. + + """ + callback_url = u'{protocol}://{base_url}{path}'.format( + protocol=("https" if self.use_https else "http"), + base_url=settings.SITE_NAME, + path=callback_url_path + ) + + # Append the key to the URL + # This is necessary because XQueue assumes that only one + # submission is active for a particular URL. + # If it receives a second submission with the same callback URL, + # it "retires" any other submission with the same URL. + # This was a hack that depended on the URL containing the user ID + # and courseware location; an assumption that does not apply + # to certificate generation. + # XQueue also truncates the callback URL to 128 characters, + # but since our key lengths are shorter than that, this should + # not affect us. + callback_url += "?key={key}".format( + key=( + task_identifier + if task_identifier is not None + else key + ) + ) + + xheader = make_xheader(callback_url, key, settings.CERT_QUEUE) (error, msg) = self.xqueue_interface.send_to_queue( header=xheader, body=json.dumps(contents)) if error: - LOGGER.critical(u'Unable to add a request to the queue: %s %s', unicode(error), msg) - raise Exception('Unable to send queue message') + exc = XQueueAddToQueueError(error, msg) + LOGGER.critical(unicode(exc)) + raise exc diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py new file mode 100644 index 0000000000..75cf9b1f09 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -0,0 +1,257 @@ +"""Tests for the certificates Python API. """ +from contextlib import contextmanager +import ddt + +from django.test import TestCase, RequestFactory +from django.test.utils import override_settings +from mock import patch, Mock + +from opaque_keys.edx.locator import CourseLocator +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +from course_modes.tests.factories import CourseModeFactory +from config_models.models import cache + +from certificates import api as certs_api +from certificates.models import ( + CertificateStatuses, + CertificateGenerationConfiguration, + ExampleCertificate +) +from certificates.queue import XQueueCertInterface +from certificates.tests.factories import GeneratedCertificateFactory + + +class CertificateDownloadableStatusTests(ModuleStoreTestCase): + """Tests for the `certificate_downloadable_status` helper function. """ + + def setUp(self): + super(CertificateDownloadableStatusTests, self).setUp() + + self.student = UserFactory() + self.student_no_cert = UserFactory() + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course' + ) + + self.request_factory = RequestFactory() + + def test_user_cert_status_with_generating(self): + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.generating, + mode='verified' + ) + + self.assertEqual( + certs_api.certificate_downloadable_status(self.student, self.course.id), + { + 'is_downloadable': False, + 'is_generating': True, + 'download_url': None + } + ) + + def test_user_cert_status_with_error(self): + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.error, + mode='verified' + ) + + self.assertEqual( + certs_api.certificate_downloadable_status(self.student, self.course.id), + { + 'is_downloadable': False, + 'is_generating': True, + 'download_url': None + } + ) + + def test_user_with_out_cert(self): + self.assertEqual( + certs_api.certificate_downloadable_status(self.student_no_cert, self.course.id), + { + 'is_downloadable': False, + 'is_generating': False, + 'download_url': None + } + ) + + def test_user_with_downloadable_cert(self): + GeneratedCertificateFactory.create( + user=self.student, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + mode='verified', + download_url='www.google.com' + ) + + self.assertEqual( + certs_api.certificate_downloadable_status(self.student, self.course.id), + { + 'is_downloadable': True, + 'is_generating': False, + 'download_url': 'www.google.com' + } + ) + + +class GenerateUserCertificatesTest(ModuleStoreTestCase): + """Tests for the `generate_user_certificates` helper function. """ + + def setUp(self): + super(GenerateUserCertificatesTest, self).setUp() + + self.student = UserFactory() + self.student_no_cert = UserFactory() + self.course = CourseFactory.create( + org='edx', + number='verified', + display_name='Verified Course', + grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5} + ) + self.enrollment = CourseEnrollment.enroll(self.student, self.course.id, mode='honor') + self.request_factory = RequestFactory() + + @override_settings(CERT_QUEUE='certificates') + @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})) + def test_new_cert_requests_into_xqueue_returns_generating(self): + # Mock `grade.grade` and return a summary with passing score. + # New requests save into xqueue and return the status + with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_send_to_queue: + mock_send_to_queue.return_value = (0, "Successfully queued") + result = certs_api.generate_user_certificates(self.student, self.course) + self.assertEqual(result, 'generating') + + +@ddt.ddt +class CertificateGenerationEnabledTest(TestCase): + """Test enabling/disabling self-generated certificates for a course. """ + + COURSE_KEY = CourseLocator(org='test', course='test', run='test') + + def setUp(self): + super(CertificateGenerationEnabledTest, self).setUp() + + # Since model-based configuration is cached, we need + # to clear the cache before each test. + cache.clear() + + @ddt.data( + (None, None, False), + (False, None, False), + (False, True, False), + (True, None, False), + (True, False, False), + (True, True, True) + ) + @ddt.unpack + def test_cert_generation_enabled(self, is_feature_enabled, is_course_enabled, expect_enabled): + if is_feature_enabled is not None: + CertificateGenerationConfiguration.objects.create(enabled=is_feature_enabled) + + if is_course_enabled is not None: + certs_api.set_cert_generation_enabled(self.COURSE_KEY, is_course_enabled) + + self._assert_enabled_for_course(self.COURSE_KEY, expect_enabled) + + def test_latest_setting_used(self): + # Enable the feature + CertificateGenerationConfiguration.objects.create(enabled=True) + + # Enable for the course + certs_api.set_cert_generation_enabled(self.COURSE_KEY, True) + self._assert_enabled_for_course(self.COURSE_KEY, True) + + # Disable for the course + certs_api.set_cert_generation_enabled(self.COURSE_KEY, False) + self._assert_enabled_for_course(self.COURSE_KEY, False) + + def test_setting_is_course_specific(self): + # Enable the feature + CertificateGenerationConfiguration.objects.create(enabled=True) + + # Enable for one course + certs_api.set_cert_generation_enabled(self.COURSE_KEY, True) + self._assert_enabled_for_course(self.COURSE_KEY, True) + + # Should be disabled for another course + other_course = CourseLocator(org='other', course='other', run='other') + self._assert_enabled_for_course(other_course, False) + + def _assert_enabled_for_course(self, course_key, expect_enabled): + """Check that self-generated certificates are enabled or disabled for the course. """ + actual_enabled = certs_api.cert_generation_enabled(course_key) + self.assertEqual(expect_enabled, actual_enabled) + + +class GenerateExampleCertificatesTest(TestCase): + """Test generation of example certificates. """ + + COURSE_KEY = CourseLocator(org='test', course='test', run='test') + + def setUp(self): + super(GenerateExampleCertificatesTest, self).setUp() + + def test_generate_example_certs(self): + # Generate certificates for the course + with self._mock_xqueue() as mock_queue: + certs_api.generate_example_certificates(self.COURSE_KEY) + + # Verify that the appropriate certs were added to the queue + self._assert_certs_in_queue(mock_queue, 1) + + # Verify that the certificate status is "started" + self._assert_cert_status({ + 'description': 'honor', + 'status': 'started' + }) + + def test_generate_example_certs_with_verified_mode(self): + # Create verified and honor modes for the course + CourseModeFactory(course_id=self.COURSE_KEY, mode_slug='honor') + CourseModeFactory(course_id=self.COURSE_KEY, mode_slug='verified') + + # Generate certificates for the course + with self._mock_xqueue() as mock_queue: + certs_api.generate_example_certificates(self.COURSE_KEY) + + # Verify that the appropriate certs were added to the queue + self._assert_certs_in_queue(mock_queue, 2) + + # Verify that the certificate status is "started" + self._assert_cert_status( + { + 'description': 'verified', + 'status': 'started' + }, + { + 'description': 'honor', + 'status': 'started' + } + ) + + @contextmanager + def _mock_xqueue(self): + """Mock the XQueue method for adding a task to the queue. """ + with patch.object(XQueueCertInterface, 'add_example_cert') as mock_queue: + yield mock_queue + + def _assert_certs_in_queue(self, mock_queue, expected_num): + """Check that the certificate generation task was added to the queue. """ + certs_in_queue = [call_args[0] for (call_args, __) in mock_queue.call_args_list] + self.assertEqual(len(certs_in_queue), expected_num) + for cert in certs_in_queue: + self.assertTrue(isinstance(cert, ExampleCertificate)) + + def _assert_cert_status(self, *expected_statuses): + """Check the example certificate status. """ + actual_status = certs_api.example_certificates_status(self.COURSE_KEY) + self.assertEqual(list(expected_statuses), actual_status) diff --git a/lms/djangoapps/certificates/tests/test_models.py b/lms/djangoapps/certificates/tests/test_models.py new file mode 100644 index 0000000000..261baa57f0 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_models.py @@ -0,0 +1,73 @@ +"""Tests for certificate Django models. """ +from django.test import TestCase + +from opaque_keys.edx.locator import CourseLocator +from certificates.models import ( + ExampleCertificate, + ExampleCertificateSet +) + + +class ExampleCertificateTest(TestCase): + """Tests for the ExampleCertificate model. """ + + COURSE_KEY = CourseLocator(org='test', course='test', run='test') + + DESCRIPTION = 'test' + TEMPLATE = 'test.pdf' + DOWNLOAD_URL = 'http://www.example.com' + ERROR_REASON = 'Kaboom!' + + def setUp(self): + super(ExampleCertificateTest, self).setUp() + self.cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY) + self.cert = ExampleCertificate.objects.create( + example_cert_set=self.cert_set, + description=self.DESCRIPTION, + template=self.TEMPLATE + ) + + def test_update_status_success(self): + self.cert.update_status( + ExampleCertificate.STATUS_SUCCESS, + download_url=self.DOWNLOAD_URL + ) + self.assertEqual( + self.cert.status_dict, + { + 'description': self.DESCRIPTION, + 'status': ExampleCertificate.STATUS_SUCCESS, + 'download_url': self.DOWNLOAD_URL + } + ) + + def test_update_status_error(self): + self.cert.update_status( + ExampleCertificate.STATUS_ERROR, + error_reason=self.ERROR_REASON + ) + self.assertEqual( + self.cert.status_dict, + { + 'description': self.DESCRIPTION, + 'status': ExampleCertificate.STATUS_ERROR, + 'error_reason': self.ERROR_REASON + } + ) + + def test_update_status_invalid(self): + with self.assertRaisesRegexp(ValueError, 'status'): + self.cert.update_status('invalid') + + def test_latest_status_unavailable(self): + # Delete any existing statuses + ExampleCertificateSet.objects.all().delete() + + # Verify that the "latest" status is None + result = ExampleCertificateSet.latest_status(self.COURSE_KEY) + self.assertIs(result, None) + + def test_latest_status_is_course_specific(self): + other_course = CourseLocator(org='other', course='other', run='other') + result = ExampleCertificateSet.latest_status(other_course) + self.assertIs(result, None) diff --git a/lms/djangoapps/certificates/tests/test_queue.py b/lms/djangoapps/certificates/tests/test_queue.py new file mode 100644 index 0000000000..f91469e720 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_queue.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""Tests for the XQueue certificates interface. """ +from contextlib import contextmanager +import json +from mock import patch + +from django.test import TestCase +from django.test.utils import override_settings + +from opaque_keys.edx.locator import CourseLocator + +# It is really unfortunate that we are using the XQueue client +# code from the capa library. In the future, we should move this +# into a shared library. We import it here so we can mock it +# and verify that items are being correctly added to the queue +# in our `XQueueCertInterface` implementation. +from capa.xqueue_interface import XQueueInterface + +from certificates.queue import XQueueCertInterface +from certificates.models import ExampleCertificateSet, ExampleCertificate + + +@override_settings(CERT_QUEUE='certificates') +class XQueueCertInterfaceTest(TestCase): + """Tests for the XQueue interface for certificate generation. """ + + COURSE_KEY = CourseLocator(org='test', course='test', run='test') + + TEMPLATE = 'test.pdf' + DESCRIPTION = 'test' + ERROR_MSG = 'Kaboom!' + + def setUp(self): + super(XQueueCertInterfaceTest, self).setUp() + self.xqueue = XQueueCertInterface() + + def test_add_example_cert(self): + cert = self._create_example_cert() + with self._mock_xqueue() as mock_send: + self.xqueue.add_example_cert(cert) + + # Verify that the correct payload was sent to the XQueue + self._assert_queue_task(mock_send, cert) + + # Verify the certificate status + self.assertEqual(cert.status, ExampleCertificate.STATUS_STARTED) + + def test_add_example_cert_error(self): + cert = self._create_example_cert() + with self._mock_xqueue(success=False): + self.xqueue.add_example_cert(cert) + + # Verify the error status of the certificate + self.assertEqual(cert.status, ExampleCertificate.STATUS_ERROR) + self.assertIn(self.ERROR_MSG, cert.error_reason) + + def _create_example_cert(self): + """Create an example certificate. """ + cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY) + return ExampleCertificate.objects.create( + example_cert_set=cert_set, + description=self.DESCRIPTION, + template=self.TEMPLATE + ) + + @contextmanager + def _mock_xqueue(self, success=True): + """Mock the XQueue method for sending a task to the queue. """ + with patch.object(XQueueInterface, 'send_to_queue') as mock_send: + mock_send.return_value = (0, None) if success else (1, self.ERROR_MSG) + yield mock_send + + def _assert_queue_task(self, mock_send, cert): + """Check that the task was added to the queue. """ + expected_header = { + 'lms_key': cert.access_key, + 'lms_callback_url': 'https://edx.org/update_example_certificate?key={key}'.format(key=cert.uuid), + 'queue_name': 'certificates' + } + + expected_body = { + 'action': 'create', + 'username': cert.uuid, + 'name': u'John Doë', + 'course_id': unicode(self.COURSE_KEY), + 'template_pdf': 'test.pdf', + 'example_certificate': True + } + + self.assertTrue(mock_send.called) + + __, kwargs = mock_send.call_args_list[0] + actual_header = json.loads(kwargs['header']) + actual_body = json.loads(kwargs['body']) + + self.assertEqual(expected_header, actual_header) + self.assertEqual(expected_body, actual_body) diff --git a/lms/djangoapps/certificates/tests/test_views.py b/lms/djangoapps/certificates/tests/test_views.py new file mode 100644 index 0000000000..1e846de556 --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_views.py @@ -0,0 +1,153 @@ +"""Tests for certificates views. """ + +import json +import ddt + +from django.test import TestCase +from django.core.urlresolvers import reverse +from django.core.cache import cache + +from opaque_keys.edx.locator import CourseLocator + +from certificates.models import ExampleCertificateSet, ExampleCertificate + + +@ddt.ddt +class UpdateExampleCertificateViewTest(TestCase): + """Tests for the XQueue callback that updates example certificates. """ + + COURSE_KEY = CourseLocator(org='test', course='test', run='test') + + DESCRIPTION = 'test' + TEMPLATE = 'test.pdf' + DOWNLOAD_URL = 'http://www.example.com' + ERROR_REASON = 'Kaboom!' + + def setUp(self): + super(UpdateExampleCertificateViewTest, self).setUp() + self.cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY) + self.cert = ExampleCertificate.objects.create( + example_cert_set=self.cert_set, + description=self.DESCRIPTION, + template=self.TEMPLATE, + ) + self.url = reverse('certificates.views.update_example_certificate') + + # Since rate limit counts are cached, we need to clear + # this before each test. + cache.clear() + + def test_update_example_certificate_success(self): + response = self._post_to_view(self.cert, download_url=self.DOWNLOAD_URL) + self._assert_response(response) + + self.cert = ExampleCertificate.objects.get() + self.assertEqual(self.cert.status, ExampleCertificate.STATUS_SUCCESS) + self.assertEqual(self.cert.download_url, self.DOWNLOAD_URL) + + def test_update_example_certificate_invalid_key(self): + payload = { + 'xqueue_header': json.dumps({ + 'lms_key': 'invalid' + }), + 'xqueue_body': json.dumps({ + 'username': self.cert.uuid, + 'url': self.DOWNLOAD_URL + }) + } + response = self.client.post(self.url, data=payload) + self.assertEqual(response.status_code, 404) + + def test_update_example_certificate_error(self): + response = self._post_to_view(self.cert, error_reason=self.ERROR_REASON) + self._assert_response(response) + + self.cert = ExampleCertificate.objects.get() + self.assertEqual(self.cert.status, ExampleCertificate.STATUS_ERROR) + self.assertEqual(self.cert.error_reason, self.ERROR_REASON) + + @ddt.data('xqueue_header', 'xqueue_body') + def test_update_example_certificate_invalid_params(self, missing_param): + payload = { + 'xqueue_header': json.dumps({ + 'lms_key': self.cert.access_key + }), + 'xqueue_body': json.dumps({ + 'username': self.cert.uuid, + 'url': self.DOWNLOAD_URL + }) + } + del payload[missing_param] + + response = self.client.post(self.url, data=payload) + self.assertEqual(response.status_code, 400) + + def test_update_example_certificate_missing_download_url(self): + payload = { + 'xqueue_header': json.dumps({ + 'lms_key': self.cert.access_key + }), + 'xqueue_body': json.dumps({ + 'username': self.cert.uuid + }) + } + response = self.client.post(self.url, data=payload) + self.assertEqual(response.status_code, 400) + + def test_update_example_cetificate_non_json_param(self): + payload = { + 'xqueue_header': '{/invalid', + 'xqueue_body': '{/invalid' + } + response = self.client.post(self.url, data=payload) + self.assertEqual(response.status_code, 400) + + def test_unsupported_http_method(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + def test_bad_request_rate_limiting(self): + payload = { + 'xqueue_header': json.dumps({ + 'lms_key': 'invalid' + }), + 'xqueue_body': json.dumps({ + 'username': self.cert.uuid, + 'url': self.DOWNLOAD_URL + }) + } + + # Exceed the rate limit for invalid requests + # (simulate a DDOS with invalid keys) + for _ in range(100): + response = self.client.post(self.url, data=payload) + if response.status_code == 403: + break + + # The final status code should indicate that the rate + # limit was exceeded. + self.assertEqual(response.status_code, 403) + + def _post_to_view(self, cert, download_url=None, error_reason=None): + """Simulate a callback from the XQueue to the example certificate end-point. """ + header = {'lms_key': cert.access_key} + body = {'username': cert.uuid} + + if download_url is not None: + body['url'] = download_url + + if error_reason is not None: + body['error'] = 'error' + body['error_reason'] = self.ERROR_REASON + + payload = { + 'xqueue_header': json.dumps(header), + 'xqueue_body': json.dumps(body) + } + return self.client.post(self.url, data=payload) + + def _assert_response(self, response): + """Check the response from the callback end-point. """ + content = json.loads(response.content) + self.assertEqual(response.status_code, 200) + self.assertEqual(content['return_code'], 0) diff --git a/lms/djangoapps/certificates/tests/tests_api.py b/lms/djangoapps/certificates/tests/tests_api.py deleted file mode 100644 index 6a277dfda6..0000000000 --- a/lms/djangoapps/certificates/tests/tests_api.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -Tests for the certificates api and helper function. -""" -from django.test import RequestFactory -from django.test.utils import override_settings -from mock import patch, Mock -from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from certificates.api import certificate_downloadable_status, generate_user_certificates -from student.models import CourseEnrollment - -from student.tests.factories import UserFactory -from certificates.models import CertificateStatuses -from certificates.tests.factories import GeneratedCertificateFactory - - -class CertificateDownloadableStatusTests(ModuleStoreTestCase): - """ - Tests for the certificate_downloadable_status helper function - """ - - def setUp(self): - super(CertificateDownloadableStatusTests, self).setUp() - - self.student = UserFactory() - self.student_no_cert = UserFactory() - self.course = CourseFactory.create( - org='edx', - number='verified', - display_name='Verified Course' - ) - - self.request_factory = RequestFactory() - - def test_user_cert_status_with_generating(self): - """ - in case of certificate with error means means is_generating is True and is_downloadable is False - """ - GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=CertificateStatuses.generating, - mode='verified' - ) - - self.assertEqual( - certificate_downloadable_status(self.student, self.course.id), - { - 'is_downloadable': False, - 'is_generating': True, - 'download_url': None - } - ) - - def test_user_cert_status_with_error(self): - """ - in case of certificate with error means means is_generating is True and is_downloadable is False - """ - - GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=CertificateStatuses.error, - mode='verified' - ) - - self.assertEqual( - certificate_downloadable_status(self.student, self.course.id), - { - 'is_downloadable': False, - 'is_generating': True, - 'download_url': None - } - ) - - def test_user_with_out_cert(self): - """ - in case of no certificate means is_generating is False and is_downloadable is False - """ - self.assertEqual( - certificate_downloadable_status(self.student_no_cert, self.course.id), - { - 'is_downloadable': False, - 'is_generating': False, - 'download_url': None - } - ) - - def test_user_with_downloadable_cert(self): - """ - in case of downloadable certificate means is_generating is False and is_downloadable is True - download_url has cert link - """ - - GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=CertificateStatuses.downloadable, - mode='verified', - download_url='www.google.com' - ) - - self.assertEqual( - certificate_downloadable_status(self.student, self.course.id), - { - 'is_downloadable': True, - 'is_generating': False, - 'download_url': 'www.google.com' - } - ) - - -class GenerateUserCertificatesTest(ModuleStoreTestCase): - """ - Tests for the generate_user_certificates helper function - """ - - def setUp(self): - super(GenerateUserCertificatesTest, self).setUp() - - self.student = UserFactory() - self.student_no_cert = UserFactory() - self.course = CourseFactory.create( - org='edx', - number='verified', - display_name='Verified Course', - grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5} - ) - self.enrollment = CourseEnrollment.enroll(self.student, self.course.id, mode='honor') - self.request_factory = RequestFactory() - - @override_settings(CERT_QUEUE='certificates') - @patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})) - def test_new_cert_requests_into_xqueue_returns_generating(self): - """ - mocking grade.grade and returns a summary with passing score. - new requests saves into xqueue and returns the status - """ - with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_send_to_queue: - mock_send_to_queue.return_value = (0, "Successfully queued") - self.assertEqual(generate_user_certificates(self.student, self.course), 'generating') diff --git a/lms/djangoapps/certificates/views.py b/lms/djangoapps/certificates/views.py index 13d341dc1a..09d2fb4ffc 100644 --- a/lms/djangoapps/certificates/views.py +++ b/lms/djangoapps/certificates/views.py @@ -4,14 +4,21 @@ import json import logging from django.contrib.auth.models import User -from django.http import HttpResponse +from django.http import HttpResponse, Http404, HttpResponseForbidden from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST from capa.xqueue_interface import XQUEUE_METRIC_NAME -from certificates.models import certificate_status_for_student, CertificateStatuses, GeneratedCertificate +from certificates.models import ( + certificate_status_for_student, + CertificateStatuses, + GeneratedCertificate, + ExampleCertificate +) from certificates.queue import XQueueCertInterface -from xmodule.course_module import CourseDescriptor from xmodule.modulestore.django import modulestore +from util.json_request import JsonResponse, JsonResponseBadRequest +from util.bad_request_rate_limiter import BadRequestRateLimiter from opaque_keys.edx.locations import SlashSeparatedCourseKey logger = logging.getLogger(__name__) @@ -121,3 +128,99 @@ def update_certificate(request): cert.save() return HttpResponse(json.dumps({'return_code': 0}), mimetype='application/json') + + +@csrf_exempt +@require_POST +def update_example_certificate(request): + """Callback from the XQueue that updates example certificates. + + Example certificates are used to verify that certificate + generation is configured correctly for a course. + + Unlike other certificates, example certificates + are not associated with a particular user or displayed + to students. + + For this reason, we need a different end-point to update + the status of generated example certificates. + + Arguments: + request (HttpRequest) + + Returns: + HttpResponse (200): Status was updated successfully. + HttpResponse (400): Invalid parameters. + HttpResponse (403): Rate limit exceeded for bad requests. + HttpResponse (404): Invalid certificate identifier or access key. + + """ + logger.info(u"Received response for example certificate from XQueue.") + + rate_limiter = BadRequestRateLimiter() + + # Check the parameters and rate limits + # If these are invalid, return an error response. + if rate_limiter.is_rate_limit_exceeded(request): + logger.info(u"Bad request rate limit exceeded for update example certificate end-point.") + return HttpResponseForbidden("Rate limit exceeded") + + if 'xqueue_body' not in request.POST: + logger.info(u"Missing parameter 'xqueue_body' for update example certificate end-point") + rate_limiter.tick_bad_request_counter(request) + return JsonResponseBadRequest("Parameter 'xqueue_body' is required.") + + if 'xqueue_header' not in request.POST: + logger.info(u"Missing parameter 'xqueue_header' for update example certificate end-point") + rate_limiter.tick_bad_request_counter(request) + return JsonResponseBadRequest("Parameter 'xqueue_header' is required.") + + try: + xqueue_body = json.loads(request.POST['xqueue_body']) + xqueue_header = json.loads(request.POST['xqueue_header']) + except (ValueError, TypeError): + logger.info(u"Could not decode params to example certificate end-point as JSON.") + rate_limiter.tick_bad_request_counter(request) + return JsonResponseBadRequest("Parameters must be JSON-serialized.") + + # Attempt to retrieve the example certificate record + # so we can update the status. + try: + uuid = xqueue_body.get('username') + access_key = xqueue_header.get('lms_key') + cert = ExampleCertificate.objects.get(uuid=uuid, access_key=access_key) + except ExampleCertificate.DoesNotExist: + # If we are unable to retrieve the record, it means the uuid or access key + # were not valid. This most likely means that the request is NOT coming + # from the XQueue. Return a 404 and increase the bad request counter + # to protect against a DDOS attack. + logger.info(u"Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key) + rate_limiter.tick_bad_request_counter(request) + raise Http404 + + if 'error' in xqueue_body: + # If an error occurs, save the error message so we can fix the issue. + error_reason = xqueue_body.get('error_reason') + cert.update_status(ExampleCertificate.STATUS_ERROR, error_reason=error_reason) + logger.warning( + ( + u"Error occurred during example certificate generation for uuid '%s'. " + u"The error response was '%s'." + ), uuid, error_reason + ) + else: + # If the certificate generated successfully, save the download URL + # so we can display the example certificate. + download_url = xqueue_body.get('url') + if download_url is None: + rate_limiter.tick_bad_request_counter(request) + logger.warning(u"No download URL provided for example certificate with uuid '%s'.", uuid) + return JsonResponseBadRequest( + "Parameter 'download_url' is required for successfully generated certificates." + ) + else: + cert.update_status(ExampleCertificate.STATUS_SUCCESS, download_url=download_url) + logger.info("Successfully updated example certificate with uuid '%s'.", uuid) + + # Let the XQueue know that we handled the response + return JsonResponse({'return_code': 0}) diff --git a/lms/urls.py b/lms/urls.py index acb33a4284..2bd3d051ef 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -17,7 +17,9 @@ urlpatterns = ( # certificate view url(r'^update_certificate$', 'certificates.views.update_certificate'), + url(r'^update_example_certificate$', 'certificates.views.update_example_certificate'), url(r'^request_certificate$', 'certificates.views.request_certificate'), + url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware url(r'^dashboard$', 'student.views.dashboard', name="dashboard"), url(r'^login_ajax$', 'student.views.login_user', name="login"),