Commit 00d159f1 by Peter Fogg

Disable audit certificates for new audit enrollments.

Two new certificate statuses are introduced, 'audit_passing' and
'audit_notpassing'. These signal that the GeneratedCertificate is not
to be displayed as a cert to the user, and that they either passed or
did not. This allows us to retain existing grading logic, as well as
maintaining correctness in analytics and reporting.

Ineligible certificates are hidden by using the
`eligible_certificates` manager on GeneratedCertificate. Some places
in the coe (largely reporting, analytics, and management commands) use
the default `objects` manager, since they need access to all
certificates.

ECOM-3040
ECOM-3515
parent 13ee19bd
......@@ -590,6 +590,18 @@ class CourseMode(models.Model):
modes = cls.modes_for_course(course_id)
return min(mode.min_price for mode in modes if mode.currency.lower() == currency.lower())
@classmethod
def is_eligible_for_certificate(cls, mode_slug):
"""
Returns whether or not the given mode_slug is eligible for a
certificate. Currently all modes other than 'audit' grant a
certificate. Note that audit enrollments which existed prior
to December 2015 *were* given certificates, so there will be
GeneratedCertificate records with mode='audit' which are
eligible.
"""
return mode_slug != cls.AUDIT
def to_tuple(self):
"""
Takes a mode model and turns it into a model named tuple.
......
......@@ -421,3 +421,25 @@ class CourseModeModelTest(TestCase):
self.assertFalse(verified_mode.expiration_datetime_is_explicit)
self.assertEqual(verified_mode.expiration_datetime, now)
def test_expiration_datetime_explicitly_set_to_none(self):
""" Verify that setting the _expiration_date property does not set the explicit flag. """
verified_mode, __ = self.create_mode('verified', 'Verified Certificate')
self.assertFalse(verified_mode.expiration_datetime_is_explicit)
verified_mode.expiration_datetime = None
self.assertFalse(verified_mode.expiration_datetime_is_explicit)
self.assertIsNone(verified_mode.expiration_datetime)
@ddt.data(
(CourseMode.AUDIT, False),
(CourseMode.HONOR, True),
(CourseMode.VERIFIED, True),
(CourseMode.CREDIT_MODE, True),
(CourseMode.PROFESSIONAL, True),
(CourseMode.NO_ID_PROFESSIONAL_MODE, True),
)
@ddt.unpack
def test_eligible_for_cert(self, mode_slug, expected_eligibility):
"""Verify that non-audit modes are eligible for a cert."""
self.assertEqual(CourseMode.is_eligible_for_certificate(mode_slug), expected_eligibility)
......@@ -97,7 +97,9 @@ class Command(BaseCommand):
cert_grades = {
cert.user.username: cert.grade
for cert in list(
GeneratedCertificate.objects.filter(course_id=course_key).prefetch_related('user')
GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id=course_key
).prefetch_related('user')
)
}
print "Grading students"
......
......@@ -296,6 +296,8 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
CertificateStatuses.notpassing: 'notpassing',
CertificateStatuses.restricted: 'restricted',
CertificateStatuses.auditing: 'auditing',
CertificateStatuses.audit_passing: 'auditing',
CertificateStatuses.audit_notpassing: 'auditing',
}
default_status = 'processing'
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -21,7 +21,7 @@
LOCK TABLES `django_migrations` WRITE;
/*!40000 ALTER TABLE `django_migrations` DISABLE KEYS */;
INSERT INTO `django_migrations` VALUES (1,'contenttypes','0001_initial','2015-11-18 15:37:03.679215'),(2,'auth','0001_initial','2015-11-18 15:37:04.239453'),(3,'admin','0001_initial','2015-11-18 15:37:04.375156'),(4,'assessment','0001_initial','2015-11-18 15:37:09.037208'),(5,'contenttypes','0002_remove_content_type_name','2015-11-18 15:37:09.345424'),(6,'auth','0002_alter_permission_name_max_length','2015-11-18 15:37:09.479742'),(7,'auth','0003_alter_user_email_max_length','2015-11-18 15:37:09.677049'),(8,'auth','0004_alter_user_username_opts','2015-11-18 15:37:09.805606'),(9,'auth','0005_alter_user_last_login_null','2015-11-18 15:37:09.949394'),(10,'auth','0006_require_contenttypes_0002','2015-11-18 15:37:09.978283'),(11,'branding','0001_initial','2015-11-18 15:37:10.295010'),(12,'bulk_email','0001_initial','2015-11-18 15:37:10.849873'),(13,'bulk_email','0002_data__load_course_email_template','2015-11-18 15:37:10.939652'),(14,'certificates','0001_initial','2015-11-18 15:37:12.698538'),(15,'certificates','0002_data__certificatehtmlviewconfiguration_data','2015-11-18 15:37:12.746221'),(16,'certificates','0003_data__default_modes','2015-11-18 15:37:13.182516'),(17,'commerce','0001_data__add_ecommerce_service_user','2015-11-18 15:37:13.269990'),(18,'cors_csrf','0001_initial','2015-11-18 15:37:13.459604'),(19,'course_action_state','0001_initial','2015-11-18 15:37:14.011267'),(20,'course_groups','0001_initial','2015-11-18 15:37:15.487063'),(21,'course_modes','0001_initial','2015-11-18 15:37:15.719347'),(22,'course_overviews','0001_initial','2015-11-18 15:37:15.932886'),(23,'course_structures','0001_initial','2015-11-18 15:37:16.000954'),(24,'courseware','0001_initial','2015-11-18 15:37:18.771429'),(25,'credit','0001_initial','2015-11-18 15:37:21.519361'),(26,'dark_lang','0001_initial','2015-11-18 15:37:21.846133'),(27,'dark_lang','0002_data__enable_on_install','2015-11-18 15:37:21.995633'),(28,'default','0001_initial','2015-11-18 15:37:23.147337'),(29,'default','0002_add_related_name','2015-11-18 15:37:23.421429'),(30,'default','0003_alter_email_max_length','2015-11-18 15:37:23.512312'),(31,'django_comment_common','0001_initial','2015-11-18 15:37:24.320730'),(32,'django_notify','0001_initial','2015-11-18 15:37:25.685195'),(33,'django_openid_auth','0001_initial','2015-11-18 15:37:26.276837'),(34,'edx_proctoring','0001_initial','2015-11-18 15:37:30.965630'),(35,'edxval','0001_initial','2015-11-18 15:37:32.899299'),(36,'edxval','0002_data__default_profiles','2015-11-18 15:37:32.962490'),(37,'embargo','0001_initial','2015-11-18 15:37:34.781531'),(38,'embargo','0002_data__add_countries','2015-11-18 15:37:36.006428'),(39,'external_auth','0001_initial','2015-11-18 15:37:36.857447'),(40,'foldit','0001_initial','2015-11-18 15:37:38.106002'),(41,'instructor_task','0001_initial','2015-11-18 15:37:38.670378'),(42,'licenses','0001_initial','2015-11-18 15:37:39.225872'),(43,'lms_xblock','0001_initial','2015-11-18 15:37:39.674372'),(44,'milestones','0001_initial','2015-11-18 15:37:41.240336'),(45,'milestones','0002_data__seed_relationship_types','2015-11-18 15:37:41.287383'),(46,'mobile_api','0001_initial','2015-11-18 15:37:41.822343'),(47,'notes','0001_initial','2015-11-18 15:37:42.381389'),(48,'oauth2','0001_initial','2015-11-18 15:37:45.638217'),(49,'oauth2_provider','0001_initial','2015-11-18 15:37:46.173938'),(50,'oauth_provider','0001_initial','2015-11-18 15:37:47.398936'),(51,'organizations','0001_initial','2015-11-18 15:37:47.863329'),(52,'programs','0001_initial','2015-11-18 15:37:48.427080'),(53,'psychometrics','0001_initial','2015-11-18 15:37:48.987109'),(54,'self_paced','0001_initial','2015-11-18 15:37:49.523078'),(55,'sessions','0001_initial','2015-11-18 15:37:49.646106'),(56,'student','0001_initial','2015-11-18 15:38:05.543390'),(57,'shoppingcart','0001_initial','2015-11-18 15:38:21.727512'),(58,'sites','0001_initial','2015-11-18 15:38:21.837391'),(59,'splash','0001_initial','2015-11-18 15:38:23.244678'),(60,'status','0001_initial','2015-11-18 15:38:24.981737'),(61,'submissions','0001_initial','2015-11-18 15:38:26.499689'),(62,'survey','0001_initial','2015-11-18 15:38:28.962457'),(63,'teams','0001_initial','2015-11-18 15:38:31.641489'),(64,'third_party_auth','0001_initial','2015-11-18 15:38:35.861826'),(65,'track','0001_initial','2015-11-18 15:38:35.952939'),(66,'user_api','0001_initial','2015-11-18 15:38:42.846323'),(67,'util','0001_initial','2015-11-18 15:38:43.780691'),(68,'util','0002_data__default_rate_limit_config','2015-11-18 15:38:43.844470'),(69,'verify_student','0001_initial','2015-11-18 15:39:02.098134'),(70,'wiki','0001_initial','2015-11-18 15:39:50.801336'),(71,'workflow','0001_initial','2015-11-18 15:39:51.336698'),(72,'xblock_django','0001_initial','2015-11-18 15:39:52.487567'),(73,'contentstore','0001_initial','2015-11-18 15:40:36.154872'),(74,'course_creators','0001_initial','2015-11-18 15:40:36.715711'),(75,'xblock_config','0001_initial','2015-11-18 15:40:38.167185');
INSERT INTO `django_migrations` VALUES (1,'contenttypes','0001_initial','2016-03-10 21:02:18.260628'),(2,'auth','0001_initial','2016-03-10 21:02:18.465022'),(3,'admin','0001_initial','2016-03-10 21:02:18.528057'),(4,'assessment','0001_initial','2016-03-10 21:02:21.076024'),(5,'contenttypes','0002_remove_content_type_name','2016-03-10 21:02:21.214125'),(6,'auth','0002_alter_permission_name_max_length','2016-03-10 21:02:21.277907'),(7,'auth','0003_alter_user_email_max_length','2016-03-10 21:02:21.317004'),(8,'auth','0004_alter_user_username_opts','2016-03-10 21:02:21.348775'),(9,'auth','0005_alter_user_last_login_null','2016-03-10 21:02:21.422065'),(10,'auth','0006_require_contenttypes_0002','2016-03-10 21:02:21.433636'),(11,'branding','0001_initial','2016-03-10 21:02:21.522006'),(12,'bulk_email','0001_initial','2016-03-10 21:02:21.762778'),(13,'bulk_email','0002_data__load_course_email_template','2016-03-10 21:02:21.809519'),(14,'instructor_task','0001_initial','2016-03-10 21:02:21.958763'),(15,'certificates','0001_initial','2016-03-10 21:02:22.665674'),(16,'certificates','0002_data__certificatehtmlviewconfiguration_data','2016-03-10 21:02:22.679954'),(17,'certificates','0003_data__default_modes','2016-03-10 21:02:22.738210'),(18,'certificates','0004_certificategenerationhistory','2016-03-10 21:02:22.869691'),(19,'certificates','0005_auto_20151208_0801','2016-03-10 21:02:22.939965'),(20,'commerce','0001_data__add_ecommerce_service_user','2016-03-10 21:02:22.957423'),(21,'cors_csrf','0001_initial','2016-03-10 21:02:23.030499'),(22,'course_action_state','0001_initial','2016-03-10 21:02:23.237008'),(23,'course_groups','0001_initial','2016-03-10 21:02:23.965465'),(24,'course_modes','0001_initial','2016-03-10 21:02:24.078559'),(25,'course_modes','0002_coursemode_expiration_datetime_is_explicit','2016-03-10 21:02:24.122497'),(26,'course_modes','0003_auto_20151113_1443','2016-03-10 21:02:24.143334'),(27,'course_modes','0004_auto_20151113_1457','2016-03-10 21:02:24.230123'),(28,'course_overviews','0001_initial','2016-03-10 21:02:24.291307'),(29,'course_overviews','0002_add_course_catalog_fields','2016-03-10 21:02:25.188256'),(30,'course_overviews','0003_courseoverviewgeneratedhistory','2016-03-10 21:02:25.212392'),(31,'course_overviews','0004_courseoverview_org','2016-03-10 21:02:25.256998'),(32,'course_overviews','0005_delete_courseoverviewgeneratedhistory','2016-03-10 21:02:25.277816'),(33,'course_structures','0001_initial','2016-03-10 21:02:25.303490'),(34,'courseware','0001_initial','2016-03-10 21:02:26.875590'),(35,'credit','0001_initial','2016-03-10 21:02:28.055671'),(36,'dark_lang','0001_initial','2016-03-10 21:02:28.191039'),(37,'dark_lang','0002_data__enable_on_install','2016-03-10 21:02:28.210267'),(38,'default','0001_initial','2016-03-10 21:02:28.623904'),(39,'default','0002_add_related_name','2016-03-10 21:02:28.757794'),(40,'default','0003_alter_email_max_length','2016-03-10 21:02:28.792001'),(41,'django_comment_common','0001_initial','2016-03-10 21:02:29.195893'),(42,'django_notify','0001_initial','2016-03-10 21:02:29.849286'),(43,'django_openid_auth','0001_initial','2016-03-10 21:02:30.058904'),(44,'edx_proctoring','0001_initial','2016-03-10 21:02:33.416077'),(45,'edxval','0001_initial','2016-03-10 21:02:34.027690'),(46,'edxval','0002_data__default_profiles','2016-03-10 21:02:34.061771'),(47,'embargo','0001_initial','2016-03-10 21:02:34.831364'),(48,'embargo','0002_data__add_countries','2016-03-10 21:02:35.149918'),(49,'external_auth','0001_initial','2016-03-10 21:02:35.660731'),(50,'lms_xblock','0001_initial','2016-03-10 21:02:35.887522'),(51,'milestones','0001_initial','2016-03-10 21:02:36.772432'),(52,'milestones','0002_data__seed_relationship_types','2016-03-10 21:02:36.799599'),(53,'mobile_api','0001_initial','2016-03-10 21:02:37.040219'),(54,'notes','0001_initial','2016-03-10 21:02:37.349625'),(55,'oauth2','0001_initial','2016-03-10 21:02:39.331002'),(56,'oauth2_provider','0001_initial','2016-03-10 21:02:39.564057'),(57,'oauth_provider','0001_initial','2016-03-10 21:02:40.197386'),(58,'organizations','0001_initial','2016-03-10 21:02:40.375246'),(59,'programs','0001_initial','2016-03-10 21:02:40.652142'),(60,'programs','0002_programsapiconfig_cache_ttl','2016-03-10 21:02:40.944996'),(61,'programs','0003_auto_20151120_1613','2016-03-10 21:02:42.079199'),(62,'self_paced','0001_initial','2016-03-10 21:02:42.395244'),(63,'sessions','0001_initial','2016-03-10 21:02:42.446632'),(64,'student','0001_initial','2016-03-10 21:02:53.093025'),(65,'shoppingcart','0001_initial','2016-03-10 21:03:02.773016'),(66,'shoppingcart','0002_auto_20151208_1034','2016-03-10 21:03:03.677397'),(67,'sites','0001_initial','2016-03-10 21:03:03.722504'),(68,'splash','0001_initial','2016-03-10 21:03:04.209403'),(69,'status','0001_initial','2016-03-10 21:03:05.290396'),(70,'student','0002_auto_20151208_1034','2016-03-10 21:03:06.298673'),(71,'submissions','0001_initial','2016-03-10 21:03:07.726421'),(72,'submissions','0002_auto_20151119_0913','2016-03-10 21:03:07.878500'),(73,'survey','0001_initial','2016-03-10 21:03:08.526264'),(74,'teams','0001_initial','2016-03-10 21:03:09.854204'),(75,'third_party_auth','0001_initial','2016-03-10 21:03:12.568462'),(76,'track','0001_initial','2016-03-10 21:03:12.626876'),(77,'user_api','0001_initial','2016-03-10 21:03:16.666873'),(78,'util','0001_initial','2016-03-10 21:03:17.215318'),(79,'util','0002_data__default_rate_limit_config','2016-03-10 21:03:17.256591'),(80,'verify_student','0001_initial','2016-03-10 21:03:24.649641'),(81,'verify_student','0002_auto_20151124_1024','2016-03-10 21:03:25.178614'),(82,'verify_student','0003_auto_20151113_1443','2016-03-10 21:03:25.721503'),(83,'wiki','0001_initial','2016-03-10 21:03:45.059550'),(84,'wiki','0002_remove_article_subscription','2016-03-10 21:03:45.102613'),(85,'workflow','0001_initial','2016-03-10 21:03:45.344575'),(86,'xblock_django','0001_initial','2016-03-10 21:03:46.046781'),(87,'contentstore','0001_initial','2016-03-10 21:04:02.888358'),(88,'course_creators','0001_initial','2016-03-10 21:04:02.943542'),(89,'xblock_config','0001_initial','2016-03-10 21:04:03.166075');
/*!40000 ALTER TABLE `django_migrations` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
......@@ -34,4 +34,4 @@ UNLOCK TABLES;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2015-11-18 15:40:53
-- Dump completed on 2016-03-10 21:04:06
......@@ -386,7 +386,7 @@ CREATE TABLE `auth_permission` (
PRIMARY KEY (`id`),
UNIQUE KEY `content_type_id` (`content_type_id`,`codename`),
CONSTRAINT `auth__content_type_id_508cf46651277a81_fk_django_content_type_id` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=704 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=698 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `auth_registration`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
......@@ -650,6 +650,24 @@ CREATE TABLE `certificates_certificategenerationcoursesetting` (
KEY `certificates_certificategenerationcoursesetting_c8235886` (`course_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `certificates_certificategenerationhistory`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `certificates_certificategenerationhistory` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`created` datetime(6) NOT NULL,
`modified` datetime(6) NOT NULL,
`course_id` varchar(255) NOT NULL,
`is_regeneration` tinyint(1) NOT NULL,
`generated_by_id` int(11) NOT NULL,
`instructor_task_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `certificates_ce_generated_by_id_4679598e2d7d6e10_fk_auth_user_id` (`generated_by_id`),
KEY `D794923145b81064c232a4d0bfe79880` (`instructor_task_id`),
CONSTRAINT `D794923145b81064c232a4d0bfe79880` FOREIGN KEY (`instructor_task_id`) REFERENCES `instructor_task_instructortask` (`id`),
CONSTRAINT `certificates_ce_generated_by_id_4679598e2d7d6e10_fk_auth_user_id` FOREIGN KEY (`generated_by_id`) REFERENCES `auth_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `certificates_certificatehtmlviewconfiguration`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
......@@ -767,6 +785,7 @@ CREATE TABLE `certificates_generatedcertificate` (
`user_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `certificates_generatedcertificate_user_id_552a0fa6f7d3f7e8_uniq` (`user_id`,`course_id`),
KEY `certificates_generatedcertific_verify_uuid_1b5a14bb83c471ff_uniq` (`verify_uuid`),
CONSTRAINT `certificates_generatedc_user_id_77ed5f7a53121815_fk_auth_user_id` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
......@@ -959,11 +978,26 @@ CREATE TABLE `course_modes_coursemode` (
`suggested_prices` varchar(255) NOT NULL,
`description` longtext,
`sku` varchar(255) DEFAULT NULL,
`expiration_datetime_is_explicit` tinyint(1) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `course_modes_coursemode_course_id_6fbb1796ace558b4_uniq` (`course_id`,`mode_slug`,`currency`),
KEY `course_modes_coursemode_ea134da7` (`course_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `course_modes_coursemodeexpirationconfig`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `course_modes_coursemodeexpirationconfig` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`change_date` datetime(6) NOT NULL,
`enabled` tinyint(1) NOT NULL,
`verification_window` bigint(20) NOT NULL,
`changed_by_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `course_modes_cour_changed_by_id_4d31fab2bbe98b89_fk_auth_user_id` (`changed_by_id`),
CONSTRAINT `course_modes_cour_changed_by_id_4d31fab2bbe98b89_fk_auth_user_id` FOREIGN KEY (`changed_by_id`) REFERENCES `auth_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `course_modes_coursemodesarchive`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
......@@ -1016,6 +1050,12 @@ CREATE TABLE `course_overviews_courseoverview` (
`enrollment_domain` longtext,
`invitation_only` tinyint(1) NOT NULL,
`max_student_enrollments_allowed` int(11) DEFAULT NULL,
`announcement` datetime(6),
`catalog_visibility` longtext,
`course_video_url` longtext,
`effort` longtext,
`short_description` longtext,
`org` longtext NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
......@@ -1445,7 +1485,7 @@ CREATE TABLE `django_content_type` (
`model` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `django_content_type_app_label_45f3b1d93ec8c61c_uniq` (`app_label`,`model`)
) ENGINE=InnoDB AUTO_INCREMENT=234 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=232 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `django_migrations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
......@@ -1456,7 +1496,7 @@ CREATE TABLE `django_migrations` (
`name` varchar(255) NOT NULL,
`applied` datetime(6) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=76 DEFAULT CHARSET=utf8;
) ENGINE=InnoDB AUTO_INCREMENT=90 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `django_openid_auth_association`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
......@@ -2455,8 +2495,12 @@ CREATE TABLE `programs_programsapiconfig` (
`internal_service_url` varchar(200) NOT NULL,
`public_service_url` varchar(200) NOT NULL,
`api_version_number` int(11) NOT NULL,
`enable_student_dashboard` tinyint(1) DEFAULT NULL,
`enable_student_dashboard` tinyint(1) NOT NULL,
`changed_by_id` int(11) DEFAULT NULL,
`cache_ttl` int(10) unsigned NOT NULL,
`authoring_app_css_path` varchar(255) NOT NULL,
`authoring_app_js_path` varchar(255) NOT NULL,
`enable_studio_tab` tinyint(1) NOT NULL,
PRIMARY KEY (`id`),
KEY `programs_programsa_changed_by_id_b7c3b49d5c0dcd3_fk_auth_user_id` (`changed_by_id`),
CONSTRAINT `programs_programsa_changed_by_id_b7c3b49d5c0dcd3_fk_auth_user_id` FOREIGN KEY (`changed_by_id`) REFERENCES `auth_user` (`id`)
......@@ -3229,6 +3273,22 @@ CREATE TABLE `submissions_score` (
CONSTRAINT `subm_submission_id_3fc975fe88442ff7_fk_submissions_submission_id` FOREIGN KEY (`submission_id`) REFERENCES `submissions_submission` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `submissions_scoreannotation`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `submissions_scoreannotation` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`annotation_type` varchar(255) NOT NULL,
`creator` varchar(255) NOT NULL,
`reason` longtext NOT NULL,
`score_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `submissions_sc_score_id_7b5ef248552cb857_fk_submissions_score_id` (`score_id`),
KEY `submissions_scoreannotation_fd685234` (`annotation_type`),
KEY `submissions_scoreannotation_ee243325` (`creator`),
CONSTRAINT `submissions_sc_score_id_7b5ef248552cb857_fk_submissions_score_id` FOREIGN KEY (`score_id`) REFERENCES `submissions_score` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `submissions_scoresummary`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
......@@ -3580,6 +3640,7 @@ CREATE TABLE `verify_student_historicalverificationdeadline` (
`history_date` datetime(6) NOT NULL,
`history_type` varchar(1) NOT NULL,
`history_user_id` int(11) DEFAULT NULL,
`deadline_is_explicit` tinyint(1) NOT NULL,
PRIMARY KEY (`history_id`),
KEY `verify_student__history_user_id_1e374d24cb7902c2_fk_auth_user_id` (`history_user_id`),
KEY `verify_student_historicalverificationdeadline_b80bb774` (`id`),
......@@ -3702,6 +3763,7 @@ CREATE TABLE `verify_student_verificationdeadline` (
`modified` datetime(6) NOT NULL,
`course_key` varchar(255) NOT NULL,
`deadline` datetime(6) NOT NULL,
`deadline_is_explicit` tinyint(1) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `course_key` (`course_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
......@@ -3804,18 +3866,6 @@ CREATE TABLE `wiki_articlerevision` (
CONSTRAINT `wiki_articlerevision_user_id_183520686b6ead55_fk_auth_user_id` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `wiki_articlesubscription`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `wiki_articlesubscription` (
`articleplugin_ptr_id` int(11) NOT NULL,
`subscription_ptr_id` int(11) NOT NULL,
PRIMARY KEY (`articleplugin_ptr_id`),
UNIQUE KEY `subscription_ptr_id` (`subscription_ptr_id`),
CONSTRAINT `D3cd26aee5a69a796bee9c6aeab7e317` FOREIGN KEY (`subscription_ptr_id`) REFERENCES `notify_subscription` (`subscription_id`),
CONSTRAINT `w_articleplugin_ptr_id_489742a9a302c93d_fk_wiki_articleplugin_id` FOREIGN KEY (`articleplugin_ptr_id`) REFERENCES `wiki_articleplugin` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `wiki_attachment`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
......
......@@ -77,7 +77,7 @@ def get_certificates_for_user(username):
else None
),
}
for cert in GeneratedCertificate.objects.filter(user__username=username).order_by("course_id")
for cert in GeneratedCertificate.eligible_certificates.filter(user__username=username).order_by("course_id")
]
......@@ -108,11 +108,14 @@ def generate_user_certificates(student, course_key, course=None, insecure=False,
if insecure:
xqueue.use_https = False
generate_pdf = not has_html_certificates_enabled(course_key, course)
status, cert = xqueue.add_cert(student, course_key,
course=course,
generate_pdf=generate_pdf,
forced_grade=forced_grade)
if status in [CertificateStatuses.generating, CertificateStatuses.downloadable]:
cert = xqueue.add_cert(
student,
course_key,
course=course,
generate_pdf=generate_pdf,
forced_grade=forced_grade
)
if cert.status in [CertificateStatuses.generating, CertificateStatuses.downloadable]:
emit_certificate_event('created', student, course_key, course, {
'user_id': student.id,
'course_id': unicode(course_key),
......@@ -120,7 +123,7 @@ def generate_user_certificates(student, course_key, course=None, insecure=False,
'enrollment_mode': cert.mode,
'generation_mode': generation_mode
})
return status
return cert.status
def regenerate_user_certificates(student, course_key, course=None,
......@@ -384,7 +387,7 @@ def get_certificate_url(user_id=None, course_id=None, uuid=None):
)
return url
try:
user_certificate = GeneratedCertificate.objects.get(
user_certificate = GeneratedCertificate.eligible_certificates.get(
user=user_id,
course_id=course_id
)
......
......@@ -76,7 +76,7 @@ class Command(BaseCommand):
status = options.get('status', CertificateStatuses.downloadable)
grade = options.get('grade', '')
cert, created = GeneratedCertificate.objects.get_or_create(
cert, created = GeneratedCertificate.eligible_certificates.get_or_create(
user=user,
course_id=course_key
)
......
......@@ -42,8 +42,9 @@ class Command(BaseCommand):
def handle(self, *args, **options):
course_id = options['course']
print "Fetching ungraded students for {0}".format(course_id)
ungraded = GeneratedCertificate.objects.filter(
course_id__exact=course_id).filter(grade__exact='')
ungraded = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id__exact=course_id
).filter(grade__exact='')
course = courses.get_course_by_id(course_id)
factory = RequestFactory()
request = factory.get('/')
......
......@@ -70,14 +70,17 @@ class Command(BaseCommand):
enrolled_total = User.objects.filter(
courseenrollment__course_id=course_id
)
verified_enrolled = GeneratedCertificate.objects.filter(
course_id__exact=course_id, mode__exact='verified'
verified_enrolled = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id__exact=course_id,
mode__exact='verified'
)
honor_enrolled = GeneratedCertificate.objects.filter(
course_id__exact=course_id, mode__exact='honor'
honor_enrolled = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id__exact=course_id,
mode__exact='honor'
)
audit_enrolled = GeneratedCertificate.objects.filter(
course_id__exact=course_id, mode__exact='audit'
audit_enrolled = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id__exact=course_id,
mode__exact='audit'
)
cert_data[course_id] = {
......@@ -88,7 +91,7 @@ class Command(BaseCommand):
'audit_enrolled': audit_enrolled.count()
}
status_tally = GeneratedCertificate.objects.filter(
status_tally = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id__exact=course_id
).values('status').annotate(
dcount=Count('status')
......@@ -100,7 +103,7 @@ class Command(BaseCommand):
}
)
mode_tally = GeneratedCertificate.objects.filter(
mode_tally = GeneratedCertificate.objects.filter( # pylint: disable=no-member
course_id__exact=course_id,
status__exact='downloadable'
).values('mode').annotate(
......
......@@ -81,7 +81,7 @@ class Command(BaseCommand):
# Retrieve the IDs of generated certificates with
# error status in the set of courses we're considering.
queryset = (
GeneratedCertificate.objects.select_related('user')
GeneratedCertificate.objects.select_related('user') # pylint: disable=no-member
).filter(status=CertificateStatuses.error)
if only_course_keys:
queryset = queryset.filter(course_id__in=only_course_keys)
......
......@@ -86,6 +86,8 @@ class CertificateStatuses(object):
restricted = 'restricted'
unavailable = 'unavailable'
auditing = 'auditing'
audit_passing = 'audit_passing'
audit_notpassing = 'audit_notpassing'
readable_statuses = {
downloadable: "already received",
......@@ -143,7 +145,7 @@ class CertificateWhitelist(models.Model):
if student:
white_list = white_list.filter(user=student)
result = []
generated_certificates = GeneratedCertificate.objects.filter(
generated_certificates = GeneratedCertificate.eligible_certificates.filter(
course_id=course_id,
user__in=[exception.user for exception in white_list],
status=CertificateStatuses.downloadable
......@@ -168,11 +170,42 @@ class CertificateWhitelist(models.Model):
return result
class EligibleCertificateManager(models.Manager):
"""
A manager for `GeneratedCertificate` models that automatically
filters out ineligible certs.
The idea is to prevent accidentally granting certificates to
students who have not enrolled in a cert-granting mode. The
alternative is to filter by eligible_for_certificate=True every
time certs are searched for, which is verbose and likely to be
forgotten.
"""
def get_queryset(self):
"""
Return a queryset for `GeneratedCertificate` models, filtering out
ineligible certificates.
"""
return super(EligibleCertificateManager, self).get_queryset().exclude(
status__in=(CertificateStatuses.audit_passing, CertificateStatuses.audit_notpassing)
)
class GeneratedCertificate(models.Model):
"""
Base model for generated certificates
"""
# Only returns eligible certificates. This should be used in
# preference to the default `objects` manager in most cases.
eligible_certificates = EligibleCertificateManager()
# Normal object manager, which should only be used when ineligible
# certificates (i.e. new audit certs) should be included in the
# results. Django requires us to explicitly declare this.
objects = models.Manager()
MODES = Choices('verified', 'honor', 'audit', 'professional', 'no-id-professional')
VERIFIED_CERTS_MODES = [CourseMode.VERIFIED, CourseMode.CREDIT_MODE]
......@@ -348,7 +381,7 @@ def certificate_status_for_student(student, course_id):
'''
try:
generated_certificate = GeneratedCertificate.objects.get(
generated_certificate = GeneratedCertificate.objects.get( # pylint: disable=no-member
user=student, course_id=course_id)
cert_status = {
'status': generated_certificate.status,
......
......@@ -20,6 +20,7 @@ from student.models import UserProfile, CourseEnrollment
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from certificates.models import (
CertificateStatuses,
GeneratedCertificate,
certificate_status_for_student,
CertificateStatuses as status,
......@@ -120,14 +121,14 @@ class XQueueCertInterface(object):
Change the certificate status to unavailable (if it exists) and request
grading. Passing grades will put a certificate request on the queue.
Return the status object.
Return the certificate.
"""
# TODO: when del_cert is implemented and plumbed through certificates
# repo also, do a deletion followed by a creation r/t a simple
# recreation. XXX: this leaves orphan cert files laying around in
# AWS. See note in the docstring too.
try:
certificate = GeneratedCertificate.objects.get(user=student, course_id=course_id)
certificate = GeneratedCertificate.eligible_certificates.get(user=student, course_id=course_id)
LOGGER.info(
(
......@@ -183,8 +184,7 @@ class XQueueCertInterface(object):
raise NotImplementedError
# pylint: disable=too-many-statements
def add_cert(self, student, course_id, course=None, forced_grade=None, template_file=None,
title='None', generate_pdf=True):
def add_cert(self, student, course_id, course=None, forced_grade=None, template_file=None, generate_pdf=True):
"""
Request a new certificate for a student.
......@@ -211,7 +211,7 @@ class XQueueCertInterface(object):
If a student does not have a passing grade the status
will change to status.notpassing
Returns the student's status and newly created certificate instance
Returns the newly created certificate instance
"""
valid_statuses = [
......@@ -222,10 +222,11 @@ class XQueueCertInterface(object):
status.notpassing,
status.downloadable,
status.auditing,
status.audit_passing,
status.audit_notpassing,
]
cert_status = certificate_status_for_student(student, course_id)['status']
new_status = cert_status
cert = None
if cert_status not in valid_statuses:
......@@ -240,169 +241,191 @@ class XQueueCertInterface(object):
cert_status,
unicode(valid_statuses)
)
return None
# The caller can optionally pass a course in to avoid
# re-fetching it from Mongo. If they have not provided one,
# get it from the modulestore.
if course is None:
course = modulestore().get_course(course_id, depth=0)
profile = UserProfile.objects.get(user=student)
profile_name = profile.name
# Needed for access control in grading.
self.request.user = student
self.request.session = {}
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)
mode_is_verified = enrollment_mode in GeneratedCertificate.VERIFIED_CERTS_MODES
user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student)
cert_mode = enrollment_mode
is_eligible_for_certificate = is_whitelisted or CourseMode.is_eligible_for_certificate(enrollment_mode)
# For credit mode generate verified certificate
if cert_mode == CourseMode.CREDIT_MODE:
cert_mode = CourseMode.VERIFIED
if template_file is not None:
template_pdf = template_file
elif mode_is_verified and user_is_verified:
template_pdf = "certificate-template-{id.org}-{id.course}-verified.pdf".format(id=course_id)
elif mode_is_verified and not user_is_verified:
template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
cert_mode = GeneratedCertificate.MODES.honor
else:
# grade the student
# re-use the course passed in optionally so we don't have to re-fetch everything
# for every student
if course is None:
course = modulestore().get_course(course_id, depth=0)
profile = UserProfile.objects.get(user=student)
profile_name = profile.name
# Needed
self.request.user = student
self.request.session = {}
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)
mode_is_verified = enrollment_mode in GeneratedCertificate.VERIFIED_CERTS_MODES
user_is_verified = SoftwareSecurePhotoVerification.user_is_verified(student)
cert_mode = enrollment_mode
# For credit mode generate verified certificate
if cert_mode == CourseMode.CREDIT_MODE:
cert_mode = CourseMode.VERIFIED
if mode_is_verified and user_is_verified:
template_pdf = "certificate-template-{id.org}-{id.course}-verified.pdf".format(id=course_id)
elif mode_is_verified and not user_is_verified:
template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
cert_mode = GeneratedCertificate.MODES.honor
else:
# honor code and audit students
template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
if forced_grade:
grade['grade'] = forced_grade
cert, __ = GeneratedCertificate.objects.get_or_create(user=student, course_id=course_id)
cert.mode = cert_mode
cert.user = student
cert.grade = grade['percent']
cert.course_id = course_id
cert.name = profile_name
cert.download_url = ''
# Strip HTML from grade range label
grade_contents = grade.get('grade', None)
try:
grade_contents = lxml.html.fromstring(grade_contents).text_content()
except (TypeError, XMLSyntaxError, ParserError) as exc:
# honor code and audit students
template_pdf = "certificate-template-{id.org}-{id.course}.pdf".format(id=course_id)
if forced_grade:
grade['grade'] = forced_grade
cert, created = GeneratedCertificate.objects.get_or_create(user=student, course_id=course_id) # pylint: disable=no-member
cert.mode = cert_mode
cert.user = student
cert.grade = grade['percent']
cert.course_id = course_id
cert.name = profile_name
cert.download_url = ''
# Strip HTML from grade range label
grade_contents = grade.get('grade', None)
try:
grade_contents = lxml.html.fromstring(grade_contents).text_content()
passing = True
except (TypeError, XMLSyntaxError, ParserError) as exc:
LOGGER.info(
(
u"Could not retrieve grade for student %s "
u"in the course '%s' "
u"because an exception occurred while parsing the "
u"grade contents '%s' as HTML. "
u"The exception was: '%s'"
),
student.id,
unicode(course_id),
grade_contents,
unicode(exc)
)
# Log if the student is whitelisted
if is_whitelisted:
LOGGER.info(
(
u"Could not retrieve grade for student %s "
u"in the course '%s' "
u"because an exception occurred while parsing the "
u"grade contents '%s' as HTML. "
u"The exception was: '%s'"
),
u"Student %s is whitelisted in '%s'",
student.id,
unicode(course_id),
grade_contents,
unicode(exc)
unicode(course_id)
)
# Despite blowing up the xml parser, bad values here are fine
grade_contents = None
if is_whitelisted or grade_contents is not None:
if is_whitelisted:
LOGGER.info(
u"Student %s is whitelisted in '%s'",
student.id,
unicode(course_id)
)
# check to see whether the student is on the
# the embargoed country restricted list
# otherwise, put a new certificate request
# on the queue
if self.restricted.filter(user=student).exists():
new_status = status.restricted
cert.status = new_status
cert.save()
LOGGER.info(
(
u"Student %s is in the embargoed country restricted "
u"list, so their certificate status has been set to '%s' "
u"for the course '%s'. "
u"No certificate generation task was sent to the XQueue."
),
student.id,
new_status,
unicode(course_id)
)
else:
key = make_hashkey(random.random())
cert.key = key
contents = {
'action': 'create',
'username': student.username,
'course_id': unicode(course_id),
'course_name': course_name,
'name': profile_name,
'grade': grade_contents,
'template_pdf': template_pdf,
}
if template_file:
contents['template_pdf'] = template_file
if generate_pdf:
new_status = status.generating
else:
new_status = status.downloadable
cert.verify_uuid = uuid4().hex
cert.status = new_status
cert.save()
if generate_pdf:
try:
self._send_to_xqueue(contents, key)
except XQueueAddToQueueError as exc:
new_status = ExampleCertificate.STATUS_ERROR
cert.status = new_status
cert.error_reason = unicode(exc)
cert.save()
LOGGER.critical(
(
u"Could not add certificate task to XQueue. "
u"The course was '%s' and the student was '%s'."
u"The certificate task status has been marked as 'error' "
u"and can be re-submitted with a management command."
), course_id, student.id
)
else:
LOGGER.info(
(
u"The certificate status has been set to '%s'. "
u"Sent a certificate grading task to the XQueue "
u"with the key '%s'. "
),
new_status,
key
)
passing = True
else:
new_status = status.notpassing
cert.status = new_status
cert.save()
passing = False
# If this user's enrollment is not eligible to receive a
# certificate, mark it as such for reporting and
# analytics. Only do this if the certificate is new -- we
# don't want to mark existing audit certs as ineligible.
if created and not is_eligible_for_certificate:
cert.status = CertificateStatuses.audit_passing if passing else CertificateStatuses.audit_notpassing
cert.save()
LOGGER.info(
u"Student %s with enrollment mode %s is not eligible for a certificate.",
student.id,
enrollment_mode
)
return cert
# If they are not passing, short-circuit and don't generate cert
elif not passing:
cert.status = status.notpassing
cert.save()
LOGGER.info(
(
u"Student %s does not have a grade for '%s', "
u"so their certificate status has been set to '%s'. "
u"No certificate generation task was sent to the XQueue."
),
student.id,
unicode(course_id),
cert.status
)
return cert
# Check to see whether the student is on the the embargoed
# country restricted list. If so, they should not receive a
# certificate -- set their status to restricted and log it.
if self.restricted.filter(user=student).exists():
cert.status = status.restricted
cert.save()
LOGGER.info(
(
u"Student %s is in the embargoed country restricted "
u"list, so their certificate status has been set to '%s' "
u"for the course '%s'. "
u"No certificate generation task was sent to the XQueue."
),
student.id,
cert.status,
unicode(course_id)
)
return cert
# Finally, generate the certificate and send it off.
return self._generate_cert(cert, course, student, grade_contents, template_pdf, generate_pdf)
def _generate_cert(self, cert, course, student, grade_contents, template_pdf, generate_pdf):
"""
Generate a certificate for the student. If `generate_pdf` is True,
sends a request to XQueue.
"""
course_id = unicode(course.id)
key = make_hashkey(random.random())
cert.key = key
contents = {
'action': 'create',
'username': student.username,
'course_id': course_id,
'course_name': course.display_name or course_id,
'name': cert.name,
'grade': grade_contents,
'template_pdf': template_pdf,
}
if generate_pdf:
cert.status = status.generating
else:
cert.status = status.downloadable
cert.verify_uuid = uuid4().hex
cert.save()
if generate_pdf:
try:
self._send_to_xqueue(contents, key)
except XQueueAddToQueueError as exc:
cert.status = ExampleCertificate.STATUS_ERROR
cert.error_reason = unicode(exc)
cert.save()
LOGGER.critical(
(
u"Could not add certificate task to XQueue. "
u"The course was '%s' and the student was '%s'."
u"The certificate task status has been marked as 'error' "
u"and can be re-submitted with a management command."
), course_id, student.id
)
else:
LOGGER.info(
(
u"Student %s does not have a grade for '%s', "
u"so their certificate status has been set to '%s'. "
u"No certificate generation task was sent to the XQueue."
u"The certificate status has been set to '%s'. "
u"Sent a certificate grading task to the XQueue "
u"with the key '%s'. "
),
student.id,
unicode(course_id),
new_status
cert.status,
key
)
return new_status, cert
return cert
def add_example_cert(self, example_cert):
"""Add a task to create an example certificate.
......
......@@ -228,7 +228,7 @@ class GenerateUserCertificatesTest(EventTestMixin, WebCertificateTestMixin, Modu
certs_api.generate_user_certificates(self.student, self.course.id)
# Verify that the certificate has status 'generating'
cert = GeneratedCertificate.objects.get(user=self.student, course_id=self.course.id)
cert = GeneratedCertificate.eligible_certificates.get(user=self.student, course_id=self.course.id)
self.assertEqual(cert.status, CertificateStatuses.generating)
self.assert_event_emitted(
'edx.certificate.created',
......@@ -246,7 +246,7 @@ class GenerateUserCertificatesTest(EventTestMixin, WebCertificateTestMixin, Modu
certs_api.generate_user_certificates(self.student, self.course.id)
# Verify that the certificate has been marked with status error
cert = GeneratedCertificate.objects.get(user=self.student, course_id=self.course.id)
cert = GeneratedCertificate.eligible_certificates.get(user=self.student, course_id=self.course.id)
self.assertEqual(cert.status, 'error')
self.assertIn(self.ERROR_REASON, cert.error_reason)
......@@ -260,7 +260,7 @@ class GenerateUserCertificatesTest(EventTestMixin, WebCertificateTestMixin, Modu
certs_api.generate_user_certificates(self.student, self.course.id)
# Verify that the certificate has status 'downloadable'
cert = GeneratedCertificate.objects.get(user=self.student, course_id=self.course.id)
cert = GeneratedCertificate.eligible_certificates.get(user=self.student, course_id=self.course.id)
self.assertEqual(cert.status, CertificateStatuses.downloadable)
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': False})
......
......@@ -6,6 +6,7 @@ from nose.plugins.attrib import attr
from django.test.utils import override_settings
from mock import patch
from course_modes.models import CourseMode
from opaque_keys.edx.locator import CourseLocator
from certificates.tests.factories import BadgeAssertionFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
......@@ -30,16 +31,17 @@ class CertificateManagementTest(ModuleStoreTestCase):
for __ in range(3)
]
def _create_cert(self, course_key, user, status):
def _create_cert(self, course_key, user, status, mode=CourseMode.HONOR):
"""Create a certificate entry. """
# Enroll the user in the course
CourseEnrollmentFactory.create(
user=user,
course_id=course_key
course_id=course_key,
mode=mode
)
# Create the certificate
GeneratedCertificate.objects.create(
GeneratedCertificate.eligible_certificates.create(
user=user,
course_id=course_key,
status=status
......@@ -52,7 +54,7 @@ class CertificateManagementTest(ModuleStoreTestCase):
def _assert_cert_status(self, course_key, user, expected_status):
"""Check the status of a certificate. """
cert = GeneratedCertificate.objects.get(user=user, course_id=course_key)
cert = GeneratedCertificate.eligible_certificates.get(user=user, course_id=course_key)
self.assertEqual(cert.status, expected_status)
......@@ -61,9 +63,10 @@ class CertificateManagementTest(ModuleStoreTestCase):
class ResubmitErrorCertificatesTest(CertificateManagementTest):
"""Tests for the resubmit_error_certificates management command. """
def test_resubmit_error_certificate(self):
@ddt.data(CourseMode.HONOR, CourseMode.VERIFIED)
def test_resubmit_error_certificate(self, mode):
# Create a certificate with status 'error'
self._create_cert(self.courses[0].id, self.user, CertificateStatuses.error)
self._create_cert(self.courses[0].id, self.user, CertificateStatuses.error, mode)
# Re-submit all certificates with status 'error'
with check_mongo_calls(1):
......@@ -198,7 +201,7 @@ class RegenerateCertificatesTest(CertificateManagementTest):
username=self.user.email, course=unicode(key), noop=False, insecure=True, template_file=None,
grade_value=None
)
certificate = GeneratedCertificate.objects.get(
certificate = GeneratedCertificate.eligible_certificates.get(
user=self.user,
course_id=key
)
......@@ -236,7 +239,7 @@ class UngenerateCertificatesTest(CertificateManagementTest):
course=unicode(key), noop=False, insecure=True, force=False
)
self.assertTrue(mock_send_to_queue.called)
certificate = GeneratedCertificate.objects.get(
certificate = GeneratedCertificate.eligible_certificates.get(
user=self.user,
course_id=key
)
......
......@@ -28,7 +28,7 @@ class CreateFakeCertTest(TestCase):
cert_mode='verified',
grade='0.89'
)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.COURSE_KEY)
cert = GeneratedCertificate.eligible_certificates.get(user=self.user, course_id=self.COURSE_KEY)
self.assertEqual(cert.status, 'downloadable')
self.assertEqual(cert.mode, 'verified')
self.assertEqual(cert.grade, '0.89')
......@@ -41,7 +41,7 @@ class CreateFakeCertTest(TestCase):
unicode(self.COURSE_KEY),
cert_mode='honor'
)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.COURSE_KEY)
cert = GeneratedCertificate.eligible_certificates.get(user=self.user, course_id=self.COURSE_KEY)
self.assertEqual(cert.mode, 'honor')
def test_too_few_args(self):
......
......@@ -8,13 +8,21 @@ from django.test.utils import override_settings
from nose.plugins.attrib import attr
from path import Path as path
from opaque_keys.edx.locator import CourseLocator
from certificates.models import (
ExampleCertificate,
ExampleCertificateSet,
CertificateHtmlViewConfiguration,
CertificateTemplateAsset,
BadgeImageConfiguration)
BadgeImageConfiguration,
EligibleCertificateManager,
GeneratedCertificate,
CertificateStatuses,
)
from certificates.tests.factories import GeneratedCertificateFactory
from opaque_keys.edx.locator import CourseLocator
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy()
FEATURES_INVALID_FILE_PATH['CERTS_HTML_VIEW_CONFIG_PATH'] = 'invalid/path/to/config.json'
......@@ -234,3 +242,42 @@ class CertificateTemplateAssetTest(TestCase):
certificate_template_asset = CertificateTemplateAsset.objects.get(id=1)
self.assertEqual(certificate_template_asset.asset, 'certificate_template_assets/1/picture2.jpg')
@attr('shard_1')
class EligibleCertificateManagerTest(SharedModuleStoreTestCase):
"""
Test the GeneratedCertificate model's object manager for filtering
out ineligible certs.
"""
@classmethod
def setUpClass(cls):
super(EligibleCertificateManagerTest, cls).setUpClass()
cls.courses = (CourseFactory(), CourseFactory())
def setUp(self):
super(EligibleCertificateManagerTest, self).setUp()
self.user = UserFactory()
self.eligible_cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.courses[0].id # pylint: disable=no-member
)
self.ineligible_cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.audit_passing,
user=self.user,
course_id=self.courses[1].id # pylint: disable=no-member
)
def test_filter_ineligible_certificates(self):
"""
Verify that the EligibleCertificateManager filters out
certificates marked as ineligible, and that the default object
manager for GeneratedCertificate does not filter them out.
"""
self.assertEqual(list(GeneratedCertificate.eligible_certificates.filter(user=self.user)), [self.eligible_cert])
self.assertEqual(
list(GeneratedCertificate.objects.filter(user=self.user)), # pylint: disable=no-member
[self.eligible_cert, self.ineligible_cert]
)
......@@ -9,6 +9,7 @@ from nose.plugins.attrib import attr
from django.test import TestCase
from django.test.utils import override_settings
from course_modes.models import CourseMode
from opaque_keys.edx.locator import CourseLocator
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory, CourseEnrollmentFactory
......@@ -22,13 +23,14 @@ from xmodule.modulestore.tests.factories import CourseFactory
# in our `XQueueCertInterface` implementation.
from capa.xqueue_interface import XQueueInterface
from certificates.queue import XQueueCertInterface
from certificates.models import (
ExampleCertificateSet,
ExampleCertificate,
GeneratedCertificate,
CertificateStatuses,
)
from certificates.queue import XQueueCertInterface
from certificates.tests.factories import CertificateWhitelistFactory, GeneratedCertificateFactory
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
......@@ -74,7 +76,7 @@ class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
# Verify that add_cert method does not add message to queue
self.assertFalse(mock_send.called)
certificate = GeneratedCertificate.objects.get(user=self.user, course_id=self.course.id)
certificate = GeneratedCertificate.eligible_certificates.get(user=self.user, course_id=self.course.id)
self.assertEqual(certificate.status, CertificateStatuses.downloadable)
self.assertIsNotNone(certificate.verify_uuid)
......@@ -84,7 +86,11 @@ class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
template_name = 'certificate-template-{id.org}-{id.course}.pdf'.format(
id=self.course.id
)
self.assert_queue_response(mode, mode, template_name)
mock_send = self.add_cert_to_queue(mode)
if CourseMode.is_eligible_for_certificate(mode):
self.assert_certificate_generated(mock_send, mode, template_name)
else:
self.assert_ineligible_certificate_generated(mock_send, mode)
@ddt.data('credit', 'verified')
def test_add_cert_with_verified_certificates(self, mode):
......@@ -95,10 +101,40 @@ class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
id=self.course.id
)
self.assert_queue_response(mode, 'verified', template_name)
mock_send = self.add_cert_to_queue(mode)
self.assert_certificate_generated(mock_send, 'verified', template_name)
def test_ineligible_cert_whitelisted(self):
"""Test that audit mode students can receive a certificate if they are whitelisted."""
# Enroll as audit
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode='audit'
)
# Whitelist student
CertificateWhitelistFactory(course_id=self.course.id, user=self.user_2)
# Generate certs
with patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})):
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user_2, self.course.id)
# Assert cert generated correctly
self.assertTrue(mock_send.called)
certificate = GeneratedCertificate.certificate_for_student(self.user_2, self.course.id)
self.assertIsNotNone(certificate)
self.assertEqual(certificate.mode, 'audit')
def assert_queue_response(self, mode, expected_mode, expected_template_name):
"""Dry method for course enrollment and adding request to queue."""
def add_cert_to_queue(self, mode):
"""
Dry method for course enrollment and adding request to
queue. Returns a mock object containing information about the
`XQueueInterface.send_to_queue` method, which can be used in other
assertions.
"""
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
......@@ -109,19 +145,26 @@ class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user_2, self.course.id)
return mock_send
def assert_certificate_generated(self, mock_send, expected_mode, expected_template_name):
"""
Assert that a certificate was generated with the correct mode and
template type.
"""
# Verify that the task was sent to the queue with the correct callback URL
self.assertTrue(mock_send.called)
__, kwargs = mock_send.call_args_list[0]
actual_header = json.loads(kwargs['header'])
self.assertIn('https://edx.org/update_certificate?key=', actual_header['lms_callback_url'])
certificate = GeneratedCertificate.objects.get(user=self.user_2, course_id=self.course.id)
self.assertEqual(certificate.mode, expected_mode)
body = json.loads(kwargs['body'])
self.assertIn(expected_template_name, body['template_pdf'])
certificate = GeneratedCertificate.eligible_certificates.get(user=self.user_2, course_id=self.course.id)
self.assertEqual(certificate.mode, expected_mode)
def assert_ineligible_certificate_generated(self, mock_send, expected_mode):
"""
Assert that an ineligible certificate was generated with the
......@@ -135,7 +178,7 @@ class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
course_id=self.course.id
)
self.assertFalse(certificate.eligible_for_certificate)
self.assertIn(certificate.status, (CertificateStatuses.audit_passing, CertificateStatuses.audit_notpassing))
self.assertEqual(certificate.mode, expected_mode)
@ddt.data(
......@@ -162,6 +205,37 @@ class XQueueCertInterfaceAddCertificateTest(ModuleStoreTestCase):
else:
self.assertFalse(mock_send.called)
def test_regen_audit_certs_eligibility(self):
"""
Test that existing audit certificates remain eligible even if cert
generation is re-run.
"""
# Create an existing audit enrollment and certificate
CourseEnrollmentFactory(
user=self.user_2,
course_id=self.course.id,
is_active=True,
mode=CourseMode.AUDIT,
)
GeneratedCertificateFactory(
user=self.user_2,
course_id=self.course.id,
grade='1.0',
status=CertificateStatuses.downloadable,
mode=GeneratedCertificate.MODES.audit,
)
# Run grading/cert generation again
with patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75})):
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
self.xqueue.add_cert(self.user_2, self.course.id)
self.assertEqual(
GeneratedCertificate.objects.get(user=self.user_2, course_id=self.course.id).status, # pylint: disable=no-member
CertificateStatuses.generating
)
@attr('shard_1')
@override_settings(CERT_QUEUE='certificates')
......
......@@ -64,7 +64,7 @@ class CertificateSupportTestCase(TestCase):
)
# Create certificates for the student
self.cert = GeneratedCertificate.objects.create(
self.cert = GeneratedCertificate.eligible_certificates.create(
user=self.student,
course_id=self.CERT_COURSE_KEY,
grade=self.CERT_GRADE,
......@@ -244,7 +244,7 @@ class CertificateRegenerateTests(ModuleStoreTestCase, CertificateSupportTestCase
# Check that the user's certificate was updated
# Since the student hasn't actually passed the course,
# we'd expect that the certificate status will be "notpassing"
cert = GeneratedCertificate.objects.get(user=self.student)
cert = GeneratedCertificate.eligible_certificates.get(user=self.student)
self.assertEqual(cert.status, CertificateStatuses.notpassing)
def test_regenerate_certificate_missing_params(self):
......@@ -283,7 +283,7 @@ class CertificateRegenerateTests(ModuleStoreTestCase, CertificateSupportTestCase
def test_regenerate_user_has_no_certificate(self):
# Delete the user's certificate
GeneratedCertificate.objects.all().delete()
GeneratedCertificate.eligible_certificates.all().delete()
# Should be able to regenerate
response = self._regenerate(
......@@ -293,7 +293,7 @@ class CertificateRegenerateTests(ModuleStoreTestCase, CertificateSupportTestCase
self.assertEqual(response.status_code, 200)
# A new certificate is created
num_certs = GeneratedCertificate.objects.filter(user=self.student).count()
num_certs = GeneratedCertificate.eligible_certificates.filter(user=self.student).count()
self.assertEqual(num_certs, 1)
def _regenerate(self, course_key=None, username=None):
......
......@@ -210,7 +210,7 @@ class MicrositeCertificatesViewsTests(ModuleStoreTestCase):
self.user.profile.name = "Joe User"
self.user.profile.save()
self.client.login(username=self.user.username, password='foo')
self.cert = GeneratedCertificate.objects.create(
self.cert = GeneratedCertificate.eligible_certificates.create(
user=self.user,
course_id=self.course_id,
download_uuid=uuid4(),
......
......@@ -13,6 +13,7 @@ from django.core.urlresolvers import reverse
from django.test.client import Client
from django.test.utils import override_settings
from course_modes.models import CourseMode
from openedx.core.lib.tests.assertions.events import assert_event_matches
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.roles import CourseStaffRole
......@@ -95,7 +96,8 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
)
CourseEnrollmentFactory.create(
user=self.user,
course_id=self.course_id
course_id=self.course_id,
mode=CourseMode.HONOR,
)
CertificateHtmlViewConfigurationFactory.create()
LinkedInAddToProfileConfigurationFactory.create()
......@@ -360,6 +362,38 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
self.assertIn("Cannot Find Certificate", response.content)
self.assertIn("We cannot find a certificate with this URL or ID number.", response.content)
@ddt.data(
(CertificateStatuses.downloadable, True),
(CertificateStatuses.audit_passing, False),
(CertificateStatuses.audit_notpassing, False),
)
@ddt.unpack
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_audit_certificate_display(self, status, eligible_for_certificate):
"""
Ensure that audit-mode certs are only shown in the web view if they
are eligible for a certificate.
"""
# Convert the cert to audit, with the specified eligibility
self.cert.mode = 'audit'
self.cert.status = status
self.cert.save()
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
course_id=unicode(self.course.id)
)
response = self.client.get(test_url)
if eligible_for_certificate:
self.assertIn(str(self.cert.verify_uuid), response.content)
else:
self.assertIn("Invalid Certificate", response.content)
self.assertIn("Cannot Find Certificate", response.content)
self.assertIn("We cannot find a certificate with this URL or ID number.", response.content)
self.assertNotIn(str(self.cert.verify_uuid), response.content)
@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
def test_html_view_for_invalid_certificate(self):
"""
......@@ -515,7 +549,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
course_id=unicode(self.course.id)
)
self.cert.delete()
self.assertEqual(len(GeneratedCertificate.objects.all()), 0)
self.assertEqual(len(GeneratedCertificate.eligible_certificates.all()), 0)
response = self.client.get(test_url)
self.assertIn('invalid', response.content)
......@@ -538,7 +572,7 @@ class CertificatesViewsTests(ModuleStoreTestCase, EventTrackingTestCase):
preview mode. Either the certificate is marked active or not.
"""
self.cert.delete()
self.assertEqual(len(GeneratedCertificate.objects.all()), 0)
self.assertEqual(len(GeneratedCertificate.eligible_certificates.all()), 0)
self._add_course_certificates(count=1, signatory_count=2)
test_url = get_certificate_url(
user_id=self.user.id,
......
......@@ -345,7 +345,7 @@ def _get_user_certificate(request, user, course_key, course, preview_mode=None):
else:
# certificate is being viewed by learner or public
try:
user_certificate = GeneratedCertificate.objects.get(
user_certificate = GeneratedCertificate.eligible_certificates.get(
user=user,
course_id=course_key,
status=CertificateStatuses.downloadable
......@@ -463,7 +463,7 @@ def render_cert_by_uuid(request, certificate_uuid):
This public view generates an HTML representation of the specified certificate
"""
try:
certificate = GeneratedCertificate.objects.get(
certificate = GeneratedCertificate.eligible_certificates.get(
verify_uuid=certificate_uuid,
status=CertificateStatuses.downloadable
)
......
......@@ -75,7 +75,7 @@ def update_certificate(request):
try:
course_key = SlashSeparatedCourseKey.from_deprecated_string(xqueue_body['course_id'])
cert = GeneratedCertificate.objects.get(
cert = GeneratedCertificate.eligible_certificates.get(
user__username=xqueue_body['username'],
course_id=course_key,
key=xqueue_header['lms_key'])
......
......@@ -618,7 +618,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase):
# Verify that certificate exception successfully removed from CertificateWhitelist and GeneratedCertificate
with self.assertRaises(ObjectDoesNotExist):
CertificateWhitelist.objects.get(user=self.user2, course_id=self.course.id)
GeneratedCertificate.objects.get(
GeneratedCertificate.eligible_certificates.get(
user=self.user2, course_id=self.course.id, status__not=CertificateStatuses.unavailable
)
......
......@@ -2819,7 +2819,10 @@ def remove_certificate_exception(course_key, student):
)
try:
generated_certificate = GeneratedCertificate.objects.get(user=student, course_id=course_key)
generated_certificate = GeneratedCertificate.objects.get( # pylint: disable=no-member
user=student,
course_id=course_key
)
generated_certificate.invalidate()
except ObjectDoesNotExist:
# Certificate has not been generated yet, so just remove the certificate exception from white list
......
......@@ -185,7 +185,7 @@ def issued_certificates(course_key, features):
report_run_date = datetime.date.today().strftime("%B %d, %Y")
certificate_features = [x for x in CERTIFICATE_FEATURES if x in features]
generated_certificates = list(GeneratedCertificate.objects.filter(
generated_certificates = list(GeneratedCertificate.eligible_certificates.filter(
course_id=course_key,
status=CertificateStatuses.downloadable
).values(*certificate_features).annotate(total_issued_certificate=Count('mode')))
......
......@@ -1580,7 +1580,7 @@ def invalidate_generated_certificates(course_id, enrolled_students, certificate_
:param enrolled_students: (queryset or list) students enrolled in the course
:param certificate_statuses: certificates statuses for whom to remove generated certificate
"""
certificates = GeneratedCertificate.objects.filter(
certificates = GeneratedCertificate.objects.filter( # pylint: disable=no-member
user__in=enrolled_students,
course_id=course_id,
status__in=certificate_statuses,
......
......@@ -1802,7 +1802,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
},
result
)
generated_certificates = GeneratedCertificate.objects.filter(
generated_certificates = GeneratedCertificate.eligible_certificates.filter(
user__in=students,
course_id=self.course.id,
mode='honor'
......@@ -1912,7 +1912,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
result
)
generated_certificates = GeneratedCertificate.objects.filter(
generated_certificates = GeneratedCertificate.eligible_certificates.filter(
user__in=students,
course_id=self.course.id,
mode='honor'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment