feat: provide command to regenerate certs for honor code courses
Only courses which actually have honor code on and only unverified certs will be regenerated. All other certs wouldn't change so don't cause trouble. MST-855
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Management command to regenerate unverified certificates when a course
|
||||
transitions to honor code.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.certificates.generation_handler import generate_certificate_task
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from openedx.core.djangoapps.agreements.toggles import is_integrity_signature_enabled
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Management command to regenerate unverified certificates when a course
|
||||
transitions to honor code.
|
||||
"""
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'-c', '--course-keys',
|
||||
nargs='+',
|
||||
dest='course_keys',
|
||||
help='course run key or space separated list of course run keys'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch_size',
|
||||
action='store',
|
||||
dest='batch_size',
|
||||
type=int,
|
||||
default=200,
|
||||
help='Number of certs per batch'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--sleep_seconds',
|
||||
action='store',
|
||||
dest='sleep_seconds',
|
||||
type=int,
|
||||
default=20,
|
||||
help='Seconds to sleep between batches'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
courses_str = options['course_keys']
|
||||
if not courses_str:
|
||||
raise CommandError('You must specify a course-key or keys')
|
||||
|
||||
batch_size = options['batch_size']
|
||||
sleep_seconds = options['sleep_seconds']
|
||||
course_keys = []
|
||||
for course_id in courses_str:
|
||||
# Parse the serialized course key into a CourseKey
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
except InvalidKeyError as e:
|
||||
raise CommandError(f'{course_id} is not a valid course-key') from e
|
||||
course_keys.append(course_key)
|
||||
|
||||
count = 0
|
||||
for key in course_keys:
|
||||
#rolling count to maintain batch_size across courses
|
||||
count = _handle_course(key, batch_size, sleep_seconds, count)
|
||||
return f'{count}'
|
||||
|
||||
|
||||
def _handle_course(course_key, batch_size, sleep_seconds, count):
|
||||
"""
|
||||
Regenerates unverified status certificates for the designated course with delay seconds between certs.
|
||||
Returns how many certs were originally unverified and regenerated.
|
||||
"""
|
||||
|
||||
if not is_integrity_signature_enabled(course_key):
|
||||
log.warning(f'Skipping {course_key} which does not have honor code enabled')
|
||||
return 0
|
||||
|
||||
certs = GeneratedCertificate.objects.filter(
|
||||
course_id=course_key,
|
||||
status=CertificateStatuses.unverified,
|
||||
)
|
||||
|
||||
log.info(f'Regenerating {len(certs)} unverified certificates for {course_key}')
|
||||
|
||||
for cert in certs:
|
||||
user = User.objects.get(id=cert.user_id)
|
||||
generate_certificate_task(user, course_key, generation_mode='batch', delay_seconds=0)
|
||||
count += 1
|
||||
if count % batch_size == 0:
|
||||
time.sleep(sleep_seconds)
|
||||
return count
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Tests for the cert_generation command
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.core.management import CommandError, call_command
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
COMMAND_INTEGRITY_ENABLED = \
|
||||
'lms.djangoapps.certificates.management.commands.regenerate_noidv_cert.is_integrity_signature_enabled'
|
||||
INTEGRITY_ENABLED_METHOD = 'lms.djangoapps.certificates.generation_handler.is_integrity_signature_enabled'
|
||||
ID_VERIFIED_METHOD = 'lms.djangoapps.verify_student.services.IDVerificationService.user_is_verified'
|
||||
PASSING_GRADE_METHOD = 'lms.djangoapps.certificates.generation_handler._is_passing_grade'
|
||||
WEB_CERTS_METHOD = 'lms.djangoapps.certificates.generation_handler.has_html_certificates_enabled'
|
||||
|
||||
|
||||
# base setup is unverified users, honor code (integrity) turned on wherever imports it
|
||||
# and normal passing grade certificates for convenience
|
||||
@mock.patch(ID_VERIFIED_METHOD, mock.Mock(return_value=False))
|
||||
@mock.patch(INTEGRITY_ENABLED_METHOD, mock.Mock(return_value=True))
|
||||
@mock.patch(COMMAND_INTEGRITY_ENABLED, mock.Mock(return_value=True))
|
||||
@mock.patch(PASSING_GRADE_METHOD, mock.Mock(return_value=True))
|
||||
@mock.patch(WEB_CERTS_METHOD, mock.Mock(return_value=True))
|
||||
class RegenerateNoIDVCertTests(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the regenerate_noidv_cert management command
|
||||
"""
|
||||
|
||||
def test_command_with_missing_param_course_key(self):
|
||||
"""
|
||||
Verify command with a missing param -- course key.
|
||||
"""
|
||||
with pytest.raises(CommandError, match="You must specify a course-key or keys"):
|
||||
call_command("regenerate_noidv_cert")
|
||||
|
||||
def test_command_with_invalid_key(self):
|
||||
"""
|
||||
Verify command with an invalid course run key
|
||||
"""
|
||||
with pytest.raises(CommandError, match=" is not a valid course-key"):
|
||||
call_command("regenerate_noidv_cert", "-c", "blah")
|
||||
|
||||
def test_regeneration(self):
|
||||
"""
|
||||
Single regeneration base case
|
||||
"""
|
||||
course_run = CourseFactory()
|
||||
course_run_key = course_run.id
|
||||
|
||||
user = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.unverified
|
||||
)
|
||||
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key)
|
||||
self.assertEqual('1', regenerated)
|
||||
|
||||
def test_regeneration_verified(self):
|
||||
"""
|
||||
Only unverified certificates should get regenerated
|
||||
"""
|
||||
course_run = CourseFactory()
|
||||
course_run_key = course_run.id
|
||||
|
||||
user = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.downloadable
|
||||
)
|
||||
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key)
|
||||
self.assertEqual('0', regenerated)
|
||||
|
||||
def test_regeneration_honor_off(self):
|
||||
"""
|
||||
If a course does not have the honor code enabled, no point regenerating
|
||||
"""
|
||||
course_run = CourseFactory()
|
||||
course_run_key = course_run.id
|
||||
|
||||
user = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.unverified
|
||||
)
|
||||
|
||||
with mock.patch(COMMAND_INTEGRITY_ENABLED, mock.Mock(return_value=False)):
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key)
|
||||
self.assertEqual('0', regenerated)
|
||||
|
||||
def _multisetup(self):
|
||||
"""
|
||||
setup certs across two course runs
|
||||
"""
|
||||
course_run = CourseFactory()
|
||||
course_run_key = course_run.id
|
||||
course_run2 = CourseFactory()
|
||||
course_run_key2 = course_run2.id
|
||||
|
||||
user = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user,
|
||||
course_id=course_run_key,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.unverified
|
||||
)
|
||||
|
||||
user1 = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user1,
|
||||
course_id=course_run_key,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user1,
|
||||
course_id=course_run_key,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.unverified
|
||||
)
|
||||
|
||||
user2 = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user2,
|
||||
course_id=course_run_key2,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user2,
|
||||
course_id=course_run_key2,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.unverified
|
||||
)
|
||||
|
||||
user_verified = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user_verified,
|
||||
course_id=course_run_key,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user_verified,
|
||||
course_id=course_run_key,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.downloadable
|
||||
)
|
||||
|
||||
user_failing = UserFactory()
|
||||
CourseEnrollmentFactory(
|
||||
user=user_failing,
|
||||
course_id=course_run_key2,
|
||||
is_active=True,
|
||||
mode=CourseMode.VERIFIED,
|
||||
)
|
||||
GeneratedCertificateFactory(
|
||||
user=user_failing,
|
||||
course_id=course_run_key2,
|
||||
mode=GeneratedCertificate.MODES.verified,
|
||||
status=CertificateStatuses.notpassing
|
||||
)
|
||||
return (course_run_key, course_run_key2)
|
||||
|
||||
def test_multiple_regeneration(self):
|
||||
"""
|
||||
Verify regeneration across multiple courses and users
|
||||
"""
|
||||
course_run_key, course_run_key2 = self._multisetup()
|
||||
|
||||
#3/5 in unverified status
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key, course_run_key2)
|
||||
self.assertEqual('3', regenerated)
|
||||
|
||||
#nothing left unverified for another run
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key, course_run_key2)
|
||||
self.assertEqual('0', regenerated)
|
||||
|
||||
def test_course_at_a_time(self):
|
||||
"""
|
||||
Verify course regeneration separated
|
||||
"""
|
||||
course_run_key, course_run_key2 = self._multisetup()
|
||||
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key)
|
||||
self.assertEqual('2', regenerated)
|
||||
|
||||
regenerated = call_command("regenerate_noidv_cert", "-c", course_run_key2)
|
||||
self.assertEqual('1', regenerated)
|
||||
Reference in New Issue
Block a user