diff --git a/openedx/core/djangoapps/programs/admin.py b/openedx/core/djangoapps/programs/admin.py index 44b95b707b18e90fd9ae1c0bb7c7a5aca0c3c04e..90ba0d541dd4f5234907693e56280d8eb9107554 100644 --- a/openedx/core/djangoapps/programs/admin.py +++ b/openedx/core/djangoapps/programs/admin.py @@ -6,7 +6,7 @@ from __future__ import absolute_import from config_models.admin import ConfigurationModelAdmin from django.contrib import admin -from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.models import ProgramsApiConfig, CustomProgramsConfig class ProgramsApiConfigAdmin(ConfigurationModelAdmin): @@ -14,3 +14,4 @@ class ProgramsApiConfigAdmin(ConfigurationModelAdmin): admin.site.register(ProgramsApiConfig, ProgramsApiConfigAdmin) +admin.site.register(CustomProgramsConfig, ConfigurationModelAdmin) diff --git a/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py b/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py index 592eb5c58c6d6bad873367b13d4916276f97dbc3..dd00caa3bed790e16f42b7ec1087b5fc945987bf 100644 --- a/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py +++ b/openedx/core/djangoapps/programs/management/commands/backpopulate_program_credentials.py @@ -3,10 +3,9 @@ from __future__ import absolute_import import logging from collections import namedtuple -from functools import reduce # pylint: disable=redefined-builtin - +from functools import reduce # pylint: disable=redefined-builtin, useless-suppression from django.contrib.sites.models import Site -from django.core.management import BaseCommand +from django.core.management.base import BaseCommand, CommandError from django.db.models import Q from opaque_keys.edx.keys import CourseKey @@ -14,6 +13,8 @@ from course_modes.models import CourseMode from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates +from openedx.core.djangoapps.programs.models import CustomProgramsConfig + # TODO: Log to console, even with debug mode disabled? logger = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -38,13 +39,49 @@ class Command(BaseCommand): default=False, help='Submit tasks for processing.' ) + parser.add_argument( + '--args-from-database', + action='store_true', + default=False, + help='Use arguments from the Config model instead of the command line.', + ) + parser.add_argument( + '--program-uuids', + nargs='+', + help='Award certificates only for specific programs.', + ) + parser.add_argument( + '--usernames', + nargs='+', + help='Award certificates only to specific users.', + ) + + def get_args_from_database(self): + """ Returns an options dictionary from the current NotifyCredentialsConfig model. """ + config = CustomProgramsConfig.current() + if not config.enabled: + raise CommandError('CustomProgramsConfig is disabled, but --args-from-database was requested.') + + # We don't need fancy shell-style whitespace/quote handling - none of our arguments are complicated + argv = config.arguments.split() + + parser = self.create_parser('manage.py', 'backpopulate_program_credentials') + return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object def handle(self, *args, **options): + program_uuids, usernames = None, None + if options['args_from_database']: + logger.info('Loading arguments from the database for custom programs or learners.') + + arguments = self.get_args_from_batabase() # pylint: disable=no-member + program_uuids = arguments.get('program-uuids', None) + usernames = arguments.get('usernames', None) + logger.info('Loading programs from the catalog.') - self._load_course_runs() + self._load_course_runs(program_uuids=program_uuids) logger.info('Looking for users who may be eligible for a program certificate.') - self._load_usernames() + self._load_usernames(users=usernames) if options.get('commit'): logger.info(u'Enqueuing program certification tasks for %d candidates.', len(self.usernames)) @@ -73,12 +110,15 @@ class Command(BaseCommand): failed ) - def _load_course_runs(self): + def _load_course_runs(self, program_uuids=None): """Find all course runs which are part of a program.""" programs = [] - for site in Site.objects.all(): - logger.info(u'Loading programs from the catalog for site %s.', site.domain) - programs.extend(get_programs(site)) + if program_uuids: + programs.extend(get_programs(uuids=program_uuids)) + else: + for site in Site.objects.all(): + logger.info(u'Loading programs from the catalog for site %s.', site.domain) + programs.extend(get_programs(site)) self.course_runs = self._flatten(programs) @@ -95,7 +135,7 @@ class Command(BaseCommand): return course_runs - def _load_usernames(self): + def _load_usernames(self, users=None): """Identify a subset of users who may be eligible for a program certificate. This is done by finding users who have earned a qualifying certificate in @@ -118,3 +158,6 @@ class Command(BaseCommand): query ).values('user__username').distinct() self.usernames = [d['user__username'] for d in username_dicts] + if users: + # keeping only those learners who are in the arguments + self.usernames = list(set(self.usernames) & set(users)) diff --git a/openedx/core/djangoapps/programs/migrations/0013_customprogramsconfig.py b/openedx/core/djangoapps/programs/migrations/0013_customprogramsconfig.py new file mode 100644 index 0000000000000000000000000000000000000000..4e2260001e1080d0b9acc804aeb4c4a5a60f57b1 --- /dev/null +++ b/openedx/core/djangoapps/programs/migrations/0013_customprogramsconfig.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-12-13 07:44 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('programs', '0012_auto_20170419_0018'), + ] + + operations = [ + migrations.CreateModel( + name='CustomProgramsConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('arguments', models.TextField(blank=True, default='', help_text='Useful for manually running a Jenkins job. Specify like "--usernames A B --program-uuids X Y".')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + options={ + 'verbose_name': 'backpopulate_program_credentials argument', + }, + ), + ] diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py index 0cf5c0bb198b40d8bae35abb0ebc24d16c328d6d..1ac3fb749c455d64f9eef14802946e4660fc837c 100644 --- a/openedx/core/djangoapps/programs/models.py +++ b/openedx/core/djangoapps/programs/models.py @@ -2,6 +2,7 @@ from __future__ import absolute_import +import six from config_models.models import ConfigurationModel from django.db import models from django.utils.translation import ugettext_lazy as _ @@ -25,3 +26,21 @@ class ProgramsApiConfig(ConfigurationModel): 'Path used to construct URLs to programs marketing pages (e.g., "/foo").' ) ) + + +class CustomProgramsConfig(ConfigurationModel): # pylint: disable=model-missing-unicode, useless-suppression + """ + Manages configuration for a run of the backpopulate_program_credentials management command. + """ + class Meta(object): + app_label = 'programs' + verbose_name = 'backpopulate_program_credentials argument' + + arguments = models.TextField( + blank=True, + help_text='Useful for manually running a Jenkins job. Specify like "--usernames A B --program-uuids X Y".', + default='', + ) + + def __str__(self): + return six.text_type(self.arguments) diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index 457b24559391ecd932acf145316300a14c394dc6..24454febbdc74b3a934c8b620d5a409ae9bc4abd 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -193,6 +193,8 @@ def award_program_certificates(self, username): for program_uuid in new_program_uuids: visible_date = completed_programs[program_uuid] try: + LOGGER.info(u'Visible date for user %s : program %s is %s', username, program_uuid, + visible_date) award_program_certificate(credentials_client, username, program_uuid, visible_date) LOGGER.info(u'Awarded certificate for program %s to user %s', program_uuid, username) except exceptions.HttpNotFoundError: