From 9657645fad2e66bc93cc32de9cc7555ce0d7adb3 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 11 Jan 2024 21:45:34 +0000 Subject: [PATCH 01/10] feat: adds a certificate template modifier adds a management command to modify certificate templates, with a dry run option. Inherently unsafe; just as a search and replace on the first string match. TODO: * unit tests * make sure the multi-line string replacements work via django admin FIXES: APER-2851 --- .../commands/modify_cert_template.py | 98 ++++++++++++++ lms/djangoapps/certificates/models.py | 39 +++++- lms/djangoapps/certificates/tasks.py | 127 ++++++++++++++++-- 3 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 lms/djangoapps/certificates/management/commands/modify_cert_template.py diff --git a/lms/djangoapps/certificates/management/commands/modify_cert_template.py b/lms/djangoapps/certificates/management/commands/modify_cert_template.py new file mode 100644 index 000000000000..4c4c3baba630 --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/modify_cert_template.py @@ -0,0 +1,98 @@ +"""Management command to modify certificate templates. +""" + +import logging +import shlex +from argparse import RawDescriptionHelpFormatter + +from django.core.management.base import BaseCommand, CommandError + +from lms.djangoapps.certificates.models import ( + ModifiedCertificateTemplateCommandConfiguration, +) +from lms.djangoapps.certificates.tasks import handle_modify_cert_template + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Management command to modify certificate templates. + + Example usage: + ./manage.py lms modify_cert_template --old-text + """ + + help = """Modify one or more certificate templates. + + This is DANGEROUS. + * This uses string replacement to modify HTML-like templates, because the presence of + Django Templating makes it impossible to do true parsing. + * This isn't parameterizing the replacement text, for the same reason. It has + no way of knowing what is template language and what is HTML. + + Do not trust that this will get the conversion right without verification, + and absolutely do not accepted untrusted user input for the replacement text. This is + to be run by trusted users only. + + Always run this with dry-run or in a reliable test environment. + """ + + def add_arguments(self, parser): + parser.formatter_class = RawDescriptionHelpFormatter + parser.add_argument( + "--dry-run", + action="store_true", + help="Just show a preview of what would happen.", + ) + parser.add_argument( + "--old-text", + required=True, + help="Text to replace in the template.", + ) + parser.add_argument( + "--new-text", + required=True, + help="Replacement text for the template.", + ) + parser.add_argument( + "--templates", + nargs="+", + required=True, + help="Certificate templates to modify.", + ) + parser.add_argument( + "--args-from-database", + action="store_true", + help="Use arguments from the ModifyCertificateTemplateConfiguration model instead of the command line.", + ) + + def get_args_from_database(self): + """ + Returns an options dictionary from the current CertificateGenerationCommandConfiguration model. + """ + config = ModifiedCertificateTemplateCommandConfiguration.current() + if not config.enabled: + raise CommandError( + "ModifyCertificateTemplateCommandConfiguration is disabled, but --args-from-database was requested" + ) + + args = shlex.split(config.arguments) + parser = self.create_parser("manage.py", "modify_cert_template") + + return vars(parser.parse_args(args)) + + def handle(self, *args, **options): + # database args will override cmd line args + if options["args_from_database"]: + options = self.get_args_from_database() + + log.info( + "modify_cert_template starting, dry-run={dry_run}, templates={templates}, old-text={old}, new-text={new}".format( + dry_run=options["dry_run"], + templates=options["templates"], + old=options["old_text"], + new=options["new_text"], + ) + ) + + handle_modify_cert_template.delay(options) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index fa16112f3add..ef4466b5dbd8 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -2,11 +2,11 @@ Course certificates are created for a student and an offering of a course (a course run). """ -from datetime import timezone import json import logging import os import uuid +from datetime import timezone from config_models.models import ConfigurationModel from django.apps import apps @@ -16,11 +16,21 @@ from django.db import models, transaction from django.db.models import Count from django.dispatch import receiver - from django.utils.translation import gettext_lazy as _ from model_utils import Choices from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField +from openedx_events.learning.data import ( # lint-amnesty, pylint: disable=wrong-import-order + CertificateData, + CourseData, + UserData, + UserPersonalData, +) +from openedx_events.learning.signals import ( # lint-amnesty, pylint: disable=wrong-import-order + CERTIFICATE_CHANGED, + CERTIFICATE_CREATED, + CERTIFICATE_REVOKED, +) from simple_history.models import HistoricalRecords from common.djangoapps.student import models_api as student_api @@ -32,9 +42,6 @@ from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.features.name_affirmation_api.utils import get_name_affirmation_service -from openedx_events.learning.data import CourseData, UserData, UserPersonalData, CertificateData # lint-amnesty, pylint: disable=wrong-import-order -from openedx_events.learning.signals import CERTIFICATE_CHANGED, CERTIFICATE_CREATED, CERTIFICATE_REVOKED # lint-amnesty, pylint: disable=wrong-import-order - log = logging.getLogger(__name__) User = get_user_model() @@ -1242,6 +1249,28 @@ class Meta: get_latest_by = 'created' app_label = "certificates" +class ModifiedCertificateTemplateCommandConfiguration(ConfigurationModel): + """ + Manages configuration for a run of the modify_cert_template management command. + + .. no_pii: + """ + + class Meta: + app_label = "certificates" + verbose_name = "modify_cert_template argument" + + arguments = models.TextField( + blank=True, + help_text=( + "Arguments for the 'modify_cert_template' management command. Specify like '-template_ids '" + ), + default="", + ) + + def __str__(self): + return str(self.arguments) + class CertificateGenerationCommandConfiguration(ConfigurationModel): """ diff --git a/lms/djangoapps/certificates/tasks.py b/lms/djangoapps/certificates/tasks.py index 6d524352c378..2b710626d264 100644 --- a/lms/djangoapps/certificates/tasks.py +++ b/lms/djangoapps/certificates/tasks.py @@ -1,17 +1,20 @@ """ -Tasks that generate a course certificate for a user +Tasks that operate on course certificates for a user """ +from difflib import unified_diff from logging import getLogger +from typing import Any, Dict from celery import shared_task -from celery_utils.persist_on_failure import LoggedPersistOnFailureTask +from celery_utils.persist_on_failure import LoggedPersistOnFailureTask, LoggedTask from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys.edx.keys import CourseKey from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.generation import generate_course_certificate +from lms.djangoapps.certificates.models import CertificateTemplate log = getLogger(__name__) User = get_user_model() @@ -21,7 +24,9 @@ CERTIFICATE_DELAY_SECONDS = 2 -@shared_task(base=LoggedPersistOnFailureTask, bind=True, default_retry_delay=30, max_retries=2) +@shared_task( + base=LoggedPersistOnFailureTask, bind=True, default_retry_delay=30, max_retries=2 +) @set_code_owner_attribute def generate_certificate(self, **kwargs): # pylint: disable=unused-argument """ @@ -37,12 +42,110 @@ def generate_certificate(self, **kwargs): # pylint: disable=unused-argument - generation_mode: Used when emitting an event. Options are "self" (implying the user generated the cert themself) and "batch" for everything else. Defaults to 'batch'. """ - student = User.objects.get(id=kwargs.pop('student')) - course_key = CourseKey.from_string(kwargs.pop('course_key')) - status = kwargs.pop('status', CertificateStatuses.downloadable) - enrollment_mode = kwargs.pop('enrollment_mode') - course_grade = kwargs.pop('course_grade', '') - generation_mode = kwargs.pop('generation_mode', 'batch') - - generate_course_certificate(user=student, course_key=course_key, status=status, enrollment_mode=enrollment_mode, - course_grade=course_grade, generation_mode=generation_mode) + student = User.objects.get(id=kwargs.pop("student")) + course_key = CourseKey.from_string(kwargs.pop("course_key")) + status = kwargs.pop("status", CertificateStatuses.downloadable) + enrollment_mode = kwargs.pop("enrollment_mode") + course_grade = kwargs.pop("course_grade", "") + generation_mode = kwargs.pop("generation_mode", "batch") + + generate_course_certificate( + user=student, + course_key=course_key, + status=status, + enrollment_mode=enrollment_mode, + course_grade=course_grade, + generation_mode=generation_mode, + ) + + +@shared_task(base=LoggedTask, ignore_result=True) +@set_code_owner_attribute +def handle_modify_cert_template(options: Dict[str, Any]): + """ + Celery task to handle the modify_cert_template management command. + + Args: + FIXME + template_ids (list[string]): List of template IDs for this run. + """ + + template_ids = options["templates"] + if not template_ids: + template_ids = [] + + # FIXME Check to see if there was that particular logging configuration + log.info( + "[modify_cert_template] Attempting to modify {num} templates".format( + num=len(template_ids) + ) + ) + templates_changed = 0 + for template_id in template_ids: + template = None + try: + template = CertificateTemplate.objects.get(id=template_id) + except CertificateTemplate.DoesNotExist: + log.warning(f"Template {template_id} could not be found") + if template is not None: + log.info( + "[modify_cert_template] Calling for template {template_id} : {name}".format( + template_id=template_id, name=template.description + ) + ) + new_template = get_modified_template_text( + template.template, options["old_text"], options["new_text"] + ) + if template.template == new_template: + log.info( + "[modify_cert_template] No changes to {template_id}".format( + template_id=template_id + ) + ) + else: + log.info( + "[modify_cert_template] Modifying template {template} ({description})".format( + template=template_id, + description=template.description, + ) + ) + templates_changed += 1 + if not options["dry_run"]: + template.template = new_template + template.save() + else: + log.info( + "DRY-RUN: Not making the following template change to {id}.".format( + id=template_id + ) + ) + log.info( + "\n".join( + unified_diff( + template.template.splitlines(), + new_template.splitlines(), + lineterm="", + fromfile="old_template", + tofile="new_template", + ) + ), + ) + # FIXME commit templates_to_change + log.info( + "[modify_cert_template] Modified {num} templates".format(num=templates_changed) + ) + + +def get_modified_template_text( + template_text: str, + old: str, + new: str, +): + """ + Returns the original template text with the first instance of `old` replaced with `new`. + Case-sensitive. + + Although this is a trivial method, it's factored into its own method to allow us to + write unit tests that can be easily modified if the testing algorithm is made more complex. + """ + return template_text.replace(old, new, 1) From 18027adfae18f1c38b0d3b6887a9abfb1e9b9f0f Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 11 Jan 2024 21:56:12 +0000 Subject: [PATCH 02/10] feat: reordering includes undid my auto formatters reordering of the includes, because I'm assuming that's what the lint-amnesty directives are there for. Best practice would have a human-written comment explaining why they need to stay there, but I'm just going have to assume it's correct, Because no such human-written comment exists. FIXES: APER-2851 --- lms/djangoapps/certificates/models.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index ef4466b5dbd8..91a8f52a8b1a 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -20,17 +20,6 @@ from model_utils import Choices from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField -from openedx_events.learning.data import ( # lint-amnesty, pylint: disable=wrong-import-order - CertificateData, - CourseData, - UserData, - UserPersonalData, -) -from openedx_events.learning.signals import ( # lint-amnesty, pylint: disable=wrong-import-order - CERTIFICATE_CHANGED, - CERTIFICATE_CREATED, - CERTIFICATE_REVOKED, -) from simple_history.models import HistoricalRecords from common.djangoapps.student import models_api as student_api @@ -42,6 +31,9 @@ from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.features.name_affirmation_api.utils import get_name_affirmation_service +from openedx_events.learning.data import CourseData, UserData, UserPersonalData, CertificateData # lint-amnesty, pylint: disable=wrong-import-order +from openedx_events.learning.signals import CERTIFICATE_CHANGED, CERTIFICATE_CREATED, CERTIFICATE_REVOKED # lint-amnesty, pylint: disable=wrong-import-order + log = logging.getLogger(__name__) User = get_user_model() From ce48516c90334536c816ba67567c06987ca5d036 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 11 Jan 2024 22:08:48 +0000 Subject: [PATCH 03/10] feat: fixing a long line problem fixing a long line problem FIXES: APER-2851 --- .../certificates/management/commands/modify_cert_template.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/certificates/management/commands/modify_cert_template.py b/lms/djangoapps/certificates/management/commands/modify_cert_template.py index 4c4c3baba630..747ef68e0ba7 100644 --- a/lms/djangoapps/certificates/management/commands/modify_cert_template.py +++ b/lms/djangoapps/certificates/management/commands/modify_cert_template.py @@ -87,7 +87,8 @@ def handle(self, *args, **options): options = self.get_args_from_database() log.info( - "modify_cert_template starting, dry-run={dry_run}, templates={templates}, old-text={old}, new-text={new}".format( + "modify_cert_template starting, dry-run={dry_run}, templates={templates}, " + "old-text={old}, new-text={new}".format( dry_run=options["dry_run"], templates=options["templates"], old=options["old_text"], From 3ac66de476e4e18048d4d85b204be49019050d27 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 11 Jan 2024 22:17:19 +0000 Subject: [PATCH 04/10] feat: adding migration adding the migration for the ability to manage these command configs via django admin FIXES: APER-2851 --- ...certificatetemplatecommandconfiguration.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py diff --git a/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py b/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py new file mode 100644 index 000000000000..d318a7463659 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.23 on 2024-01-11 22:12 + +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), + ('certificates', '0035_auto_20230808_0944'), + ] + + operations = [ + migrations.CreateModel( + name='ModifiedCertificateTemplateCommandConfiguration', + 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="Arguments for the 'modify_cert_template' management command. Specify like '-template_ids '")), + ('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': 'modify_cert_template argument', + }, + ), + ] From 392110bdda16494b013d22b5b11a87d3b329e303 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 11 Jan 2024 22:27:47 +0000 Subject: [PATCH 05/10] feat: pep8 fixes pep8 FIXES: APER-2851 --- .../management/commands/modify_cert_template.py | 13 ++----------- lms/djangoapps/certificates/models.py | 1 + 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/certificates/management/commands/modify_cert_template.py b/lms/djangoapps/certificates/management/commands/modify_cert_template.py index 747ef68e0ba7..f30051461729 100644 --- a/lms/djangoapps/certificates/management/commands/modify_cert_template.py +++ b/lms/djangoapps/certificates/management/commands/modify_cert_template.py @@ -1,6 +1,5 @@ """Management command to modify certificate templates. """ - import logging import shlex from argparse import RawDescriptionHelpFormatter @@ -17,23 +16,19 @@ class Command(BaseCommand): """Management command to modify certificate templates. - Example usage: ./manage.py lms modify_cert_template --old-text """ help = """Modify one or more certificate templates. - - This is DANGEROUS. + This is DANGEROUS. * This uses string replacement to modify HTML-like templates, because the presence of - Django Templating makes it impossible to do true parsing. + Django Templating makes it impossible to do true parsing. * This isn't parameterizing the replacement text, for the same reason. It has no way of knowing what is template language and what is HTML. - Do not trust that this will get the conversion right without verification, and absolutely do not accepted untrusted user input for the replacement text. This is to be run by trusted users only. - Always run this with dry-run or in a reliable test environment. """ @@ -75,17 +70,14 @@ def get_args_from_database(self): raise CommandError( "ModifyCertificateTemplateCommandConfiguration is disabled, but --args-from-database was requested" ) - args = shlex.split(config.arguments) parser = self.create_parser("manage.py", "modify_cert_template") - return vars(parser.parse_args(args)) def handle(self, *args, **options): # database args will override cmd line args if options["args_from_database"]: options = self.get_args_from_database() - log.info( "modify_cert_template starting, dry-run={dry_run}, templates={templates}, " "old-text={old}, new-text={new}".format( @@ -95,5 +87,4 @@ def handle(self, *args, **options): new=options["new_text"], ) ) - handle_modify_cert_template.delay(options) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 91a8f52a8b1a..1798881c8199 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -1241,6 +1241,7 @@ class Meta: get_latest_by = 'created' app_label = "certificates" + class ModifiedCertificateTemplateCommandConfiguration(ConfigurationModel): """ Manages configuration for a run of the modify_cert_template management command. From cccb960f209722063beeff1acdfa9cca773ddd1d Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Tue, 16 Jan 2024 23:07:51 +0000 Subject: [PATCH 06/10] feat:tests for certificate template modifier still needs mocks for all tests to work FIXES: APER-2851 --- lms/djangoapps/certificates/admin.py | 8 +- .../commands/modify_cert_template.py | 13 +- .../tests/test_modify_certs_template.py | 47 +++++++ ...certificatetemplatecommandconfiguration.py | 4 +- lms/djangoapps/certificates/models.py | 3 +- lms/djangoapps/certificates/tasks.py | 6 +- .../certificates/tests/factories.py | 26 +++- .../certificates/tests/test_tasks.py | 121 ++++++++++++++---- 8 files changed, 183 insertions(+), 45 deletions(-) create mode 100644 lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py diff --git a/lms/djangoapps/certificates/admin.py b/lms/djangoapps/certificates/admin.py index 8b98a2c97151..3facf6f6b530 100644 --- a/lms/djangoapps/certificates/admin.py +++ b/lms/djangoapps/certificates/admin.py @@ -20,7 +20,8 @@ CertificateHtmlViewConfiguration, CertificateTemplate, CertificateTemplateAsset, - GeneratedCertificate + GeneratedCertificate, + ModifiedCertificateTemplateCommandConfiguration, ) @@ -92,6 +93,11 @@ class CertificateGenerationCourseSettingAdmin(admin.ModelAdmin): show_full_result_count = False +@admin.register(ModifiedCertificateTemplateCommandConfiguration) +class ModifiedCertificateTemplateCommandConfigurationAdmin(ConfigurationModelAdmin): + pass + + @admin.register(CertificateGenerationCommandConfiguration) class CertificateGenerationCommandConfigurationAdmin(ConfigurationModelAdmin): pass diff --git a/lms/djangoapps/certificates/management/commands/modify_cert_template.py b/lms/djangoapps/certificates/management/commands/modify_cert_template.py index f30051461729..d720c628e6d6 100644 --- a/lms/djangoapps/certificates/management/commands/modify_cert_template.py +++ b/lms/djangoapps/certificates/management/commands/modify_cert_template.py @@ -1,5 +1,4 @@ -"""Management command to modify certificate templates. -""" +"""Management command to modify certificate templates.""" import logging import shlex from argparse import RawDescriptionHelpFormatter @@ -41,18 +40,15 @@ def add_arguments(self, parser): ) parser.add_argument( "--old-text", - required=True, help="Text to replace in the template.", ) parser.add_argument( "--new-text", - required=True, help="Replacement text for the template.", ) parser.add_argument( "--templates", nargs="+", - required=True, help="Certificate templates to modify.", ) parser.add_argument( @@ -63,7 +59,7 @@ def add_arguments(self, parser): def get_args_from_database(self): """ - Returns an options dictionary from the current CertificateGenerationCommandConfiguration model. + Returns an options dictionary from the current ModifiedCertificateTemplateCommandConfiguration instance. """ config = ModifiedCertificateTemplateCommandConfiguration.current() if not config.enabled: @@ -78,6 +74,11 @@ def handle(self, *args, **options): # database args will override cmd line args if options["args_from_database"]: options = self.get_args_from_database() + # Check required arguments here. We can't rely on marking args "required" because they might come from django + if not (options["old_text"] and options["new_text"] and options["templates"]): + raise CommandError( + "The following arguments are required: --old-text, --new-text, --templates" + ) log.info( "modify_cert_template starting, dry-run={dry_run}, templates={templates}, " "old-text={old}, new-text={new}".format( diff --git a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py new file mode 100644 index 000000000000..2ab242d101f6 --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py @@ -0,0 +1,47 @@ +""" +Tests for the modify_cert_template command +""" + +import pytest +from django.core.management import CommandError, call_command +from django.test import ( + TestCase, + override_settings, +) # lint-amnesty, pylint: disable=unused-import + + +class ModifyCertTemplateTests(TestCase): + """Tests for the modify_cert_template management command""" + + def setUp(self): + super().setUp() + + def test_command_with_missing_param_old_text(self): + """Verify command with a missing param --old-text.""" + with pytest.raises( + CommandError, + match="The following arguments are required: --old-text, --new-text, --templates", + ): + call_command( + "modify_cert_template", "--new-text", "blah", "--templates", "1 2 3" + ) + + def test_command_with_missing_param_new_text(self): + """Verify command with a missing param --new-text.""" + with pytest.raises( + CommandError, + match="The following arguments are required: --old-text, --new-text, --templates", + ): + call_command( + "modify_cert_template", "--old-text", "blah", "--templates", "1 2 3" + ) + + def test_command_with_missing_param_old_text(self): + """Verify command with a missing param --templates.""" + with pytest.raises( + CommandError, + match="The following arguments are required: --old-text, --new-text, --templates", + ): + call_command( + "modify_cert_template", "--new-text", "blah", "--old-text", "xyzzy" + ) diff --git a/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py b/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py index d318a7463659..ebc6cfd95b9a 100644 --- a/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py +++ b/lms/djangoapps/certificates/migrations/0036_modifiedcertificatetemplatecommandconfiguration.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.23 on 2024-01-11 22:12 +# Generated by Django 3.2.23 on 2024-01-16 18:57 from django.conf import settings from django.db import migrations, models @@ -19,7 +19,7 @@ class Migration(migrations.Migration): ('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="Arguments for the 'modify_cert_template' management command. Specify like '-template_ids '")), + ('arguments', models.TextField(blank=True, default='', help_text='Arguments for the \'modify_cert_template\' management command. Specify like \'--old-text "foo" --new-text "bar" --template_ids \'')), ('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={ diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 1798881c8199..9c58327b99aa 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -1256,7 +1256,8 @@ class Meta: arguments = models.TextField( blank=True, help_text=( - "Arguments for the 'modify_cert_template' management command. Specify like '-template_ids '" + "Arguments for the 'modify_cert_template' management command. Specify like '--old-text \"foo\" " + "--new-text \"bar\" --template_ids '" ), default="", ) diff --git a/lms/djangoapps/certificates/tasks.py b/lms/djangoapps/certificates/tasks.py index 2b710626d264..06078e346105 100644 --- a/lms/djangoapps/certificates/tasks.py +++ b/lms/djangoapps/certificates/tasks.py @@ -66,15 +66,16 @@ def handle_modify_cert_template(options: Dict[str, Any]): Celery task to handle the modify_cert_template management command. Args: - FIXME + old_text (string): Text in the template of which the first instance should be changed + new_text (string): Replacement text for old_text template_ids (list[string]): List of template IDs for this run. + dry_run (boolean): Don't do the work, just report the changes that would happen """ template_ids = options["templates"] if not template_ids: template_ids = [] - # FIXME Check to see if there was that particular logging configuration log.info( "[modify_cert_template] Attempting to modify {num} templates".format( num=len(template_ids) @@ -130,7 +131,6 @@ def handle_modify_cert_template(options: Dict[str, Any]): ) ), ) - # FIXME commit templates_to_change log.info( "[modify_cert_template] Modified {num} templates".format(num=templates_changed) ) diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index 15d5386aa445..ad7727876076 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -6,6 +6,7 @@ import datetime from uuid import uuid4 +from factory import Sequence from factory.django import DjangoModelFactory from common.djangoapps.student.models import LinkedInAddToProfileConfiguration @@ -15,7 +16,8 @@ CertificateHtmlViewConfiguration, CertificateInvalidation, CertificateStatuses, - GeneratedCertificate + CertificateTemplate, + GeneratedCertificate, ) @@ -23,15 +25,16 @@ class GeneratedCertificateFactory(DjangoModelFactory): """ GeneratedCertificate factory """ + class Meta: model = GeneratedCertificate course_id = None status = CertificateStatuses.unavailable mode = GeneratedCertificate.MODES.honor - name = '' + name = "" verify_uuid = uuid4().hex - grade = '' + grade = "" class CertificateAllowlistFactory(DjangoModelFactory): @@ -44,7 +47,7 @@ class Meta: course_id = None allowlist = True - notes = 'Test Notes' + notes = "Test Notes" class CertificateInvalidationFactory(DjangoModelFactory): @@ -55,7 +58,7 @@ class CertificateInvalidationFactory(DjangoModelFactory): class Meta: model = CertificateInvalidation - notes = 'Test Notes' + notes = "Test Notes" active = True @@ -112,8 +115,21 @@ class CertificateDateOverrideFactory(DjangoModelFactory): """ CertificateDateOverride factory """ + class Meta: model = CertificateDateOverride date = datetime.datetime(2021, 5, 11, 0, 0, tzinfo=datetime.timezone.utc) reason = "Learner really wanted this on their birthday" + + +class CertificateTemplateFactory(DjangoModelFactory): + """CertificateTemplate factory""" + + class Meta: + model = CertificateTemplate + + name = Sequence("template{}".format) + description = Sequence("description for template{}".format) + template = "" + is_active = True diff --git a/lms/djangoapps/certificates/tests/test_tasks.py b/lms/djangoapps/certificates/tests/test_tasks.py index c2c73f053cdd..c02c5f1dbf8d 100644 --- a/lms/djangoapps/certificates/tests/test_tasks.py +++ b/lms/djangoapps/certificates/tests/test_tasks.py @@ -3,6 +3,7 @@ """ +from textwrap import dedent from unittest import mock from unittest.mock import patch @@ -13,7 +14,11 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.tasks import generate_certificate +from lms.djangoapps.certificates.tasks import ( + generate_certificate, + handle_modify_cert_template, +) +from lms.djangoapps.certificates.tests.factories import CertificateTemplateFactory @ddt.ddt @@ -21,23 +26,24 @@ class GenerateUserCertificateTest(TestCase): """ Tests for course certificate tasks """ + def setUp(self): super().setUp() self.user = UserFactory() - self.course_key = 'course-v1:edX+DemoX+Demo_Course' + self.course_key = "course-v1:edX+DemoX+Demo_Course" - @ddt.data('student', 'course_key', 'enrollment_mode') + @ddt.data("student", "course_key", "enrollment_mode") def test_missing_args(self, missing_arg): kwargs = { - 'student': self.user.id, - 'course_key': self.course_key, - 'other_arg': 'shiny', - 'enrollment_mode': CourseMode.MASTERS + "student": self.user.id, + "course_key": self.course_key, + "other_arg": "shiny", + "enrollment_mode": CourseMode.MASTERS, } del kwargs[missing_arg] - with patch('lms.djangoapps.certificates.tasks.User.objects.get'): + with patch("lms.djangoapps.certificates.tasks.User.objects.get"): with self.assertRaisesRegex(KeyError, missing_arg): generate_certificate.apply_async(kwargs=kwargs).get() @@ -48,13 +54,13 @@ def test_generation(self): enrollment_mode = CourseMode.VERIFIED with mock.patch( - 'lms.djangoapps.certificates.tasks.generate_course_certificate', - return_value=None + "lms.djangoapps.certificates.tasks.generate_course_certificate", + return_value=None, ) as mock_generate_cert: kwargs = { - 'student': self.user.id, - 'course_key': self.course_key, - 'enrollment_mode': enrollment_mode + "student": self.user.id, + "course_key": self.course_key, + "enrollment_mode": enrollment_mode, } generate_certificate.apply_async(kwargs=kwargs) @@ -63,31 +69,31 @@ def test_generation(self): course_key=CourseKey.from_string(self.course_key), status=CertificateStatuses.downloadable, enrollment_mode=enrollment_mode, - course_grade='', - generation_mode='batch' + course_grade="", + generation_mode="batch", ) def test_generation_custom(self): """ Verify the task handles certificate generation custom params """ - gen_mode = 'self' + gen_mode = "self" status = CertificateStatuses.notpassing enrollment_mode = CourseMode.AUDIT - course_grade = '0.89' + course_grade = "0.89" with mock.patch( - 'lms.djangoapps.certificates.tasks.generate_course_certificate', - return_value=None + "lms.djangoapps.certificates.tasks.generate_course_certificate", + return_value=None, ) as mock_generate_cert: kwargs = { - 'status': status, - 'student': self.user.id, - 'course_key': self.course_key, - 'course_grade': course_grade, - 'enrollment_mode': enrollment_mode, - 'generation_mode': gen_mode, - 'what_about': 'dinosaurs' + "status": status, + "student": self.user.id, + "course_key": self.course_key, + "course_grade": course_grade, + "enrollment_mode": enrollment_mode, + "generation_mode": gen_mode, + "what_about": "dinosaurs", } generate_certificate.apply_async(kwargs=kwargs) @@ -97,5 +103,66 @@ def test_generation_custom(self): status=status, enrollment_mode=enrollment_mode, course_grade=course_grade, - generation_mode=gen_mode + generation_mode=gen_mode, ) + + +class ModifyCertTemplateTests(TestCase): + """Tests for handle_modify_cert_template""" + + # FIXME: put in mocks for .get and .save + def setUp(self): + super().setUp() + self.cert1 = CertificateTemplateFactory + self.cert2 = CertificateTemplateFactory + self.cert3 = CertificateTemplateFactory + + def test_command_changes_called_templates(self): + """Verify command changes for all and only those templates for which it is called.""" + self.cert1.template = "fiddledee-doo fiddledee-dah" + self.cert2.template = "violadee-doo violadee-dah" + self.cert3.template = "fiddledee-doo fiddledee-dah" + expected1 = "fiddleeep-doo fiddledee-dah" + expected2 = "violaeep-doo violadee-dah" + expected3 = "fiddledee-doo fiddledee-dah" + options = { + "old_text": "dee", + "new_text": "eep", + "templates": [1, 2], + } + handle_modify_cert_template(options) + assert self.cert1.template == expected1 + assert self.cert2.template == expected2 + assert self.cert3.template == expected3 + + def test_dry_run(self): + """Verify command doesn't change anything on dry-run.""" + self.cert1.template = "fiddledee-doo fiddledee-dah" + self.cert2.template = "violadee-doo violadee-dah" + expected1 = "fiddledee-doo fiddledee-dah" + expected2 = "violadee-doo violadee-dah" + options = { + "old_text": "dee", + "new_text": "eep", + "templates": [1, 2], + "dry_run": True, + } + handle_modify_cert_template(options) + assert self.cert1.template == expected1 + assert self.cert2.template == expected2 + + def test_multiline_change(self): + """Verify template change works with a multiline change string.""" + self.cert1.template = "fiddledee-doo fiddledee-dah" + new_text = """ + there's something happening here + what it is ain't exactly clear + """ + expected = f"fiddle{dedent(new_text)}-doo fiddledee-dah" + options = { + "old_text": "dee", + "new_text": dedent(new_text), + "templates": [1], + } + handle_modify_cert_template(options) + assert self.cert1.template == expected From 994b8a3d49fdb4409137d42c2c0baa4629120d55 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Wed, 17 Jan 2024 21:41:21 +0000 Subject: [PATCH 07/10] feat: adding tests to modify cert template Re-factored to make the code more testable, and added some more tests, also improved dry run logging FIXES: APER-2851 --- .../tests/test_modify_certs_template.py | 3 - lms/djangoapps/certificates/tasks.py | 105 ++++++++++-------- .../certificates/tests/test_tasks.py | 62 ++++++----- 3 files changed, 95 insertions(+), 75 deletions(-) diff --git a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py index 2ab242d101f6..c6ac0d2c903a 100644 --- a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py +++ b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py @@ -13,9 +13,6 @@ class ModifyCertTemplateTests(TestCase): """Tests for the modify_cert_template management command""" - def setUp(self): - super().setUp() - def test_command_with_missing_param_old_text(self): """Verify command with a missing param --old-text.""" with pytest.raises( diff --git a/lms/djangoapps/certificates/tasks.py b/lms/djangoapps/certificates/tasks.py index 06078e346105..8229cef3948c 100644 --- a/lms/djangoapps/certificates/tasks.py +++ b/lms/djangoapps/certificates/tasks.py @@ -4,7 +4,7 @@ from difflib import unified_diff from logging import getLogger -from typing import Any, Dict +from typing import Any, Dict, List from celery import shared_task from celery_utils.persist_on_failure import LoggedPersistOnFailureTask, LoggedTask @@ -61,7 +61,7 @@ def generate_certificate(self, **kwargs): # pylint: disable=unused-argument @shared_task(base=LoggedTask, ignore_result=True) @set_code_owner_attribute -def handle_modify_cert_template(options: Dict[str, Any]): +def handle_modify_cert_template(options: Dict[str, Any]) -> None: """ Celery task to handle the modify_cert_template management command. @@ -81,7 +81,34 @@ def handle_modify_cert_template(options: Dict[str, Any]): num=len(template_ids) ) ) - templates_changed = 0 + + templates_changed = get_changed_cert_templates(options) + for template in templates_changed: + template.save() + + +def get_changed_cert_templates(options: Dict[str, Any]) -> List[CertificateTemplate]: + """ + Loop through the templates and return instances with changed template text. + + Args: + old_text (string): Text in the template of which the first instance should be changed + new_text (string): Replacement text for old_text + template_ids (list[string]): List of template IDs for this run. + dry_run (boolean): Don't do the work, just report the changes that would happen + """ + template_ids = options["templates"] + if not template_ids: + template_ids = [] + + log.info( + "[modify_cert_template] Attempting to modify {num} templates".format( + num=len(template_ids) + ) + ) + dry_run = options.get("dry_run", None) + templates_changed = [] + for template_id in template_ids: template = None try: @@ -94,8 +121,8 @@ def handle_modify_cert_template(options: Dict[str, Any]): template_id=template_id, name=template.description ) ) - new_template = get_modified_template_text( - template.template, options["old_text"], options["new_text"] + new_template = template.template.replace( + options["old_text"], options["new_text"], 1 ) if template.template == new_template: log.info( @@ -104,48 +131,36 @@ def handle_modify_cert_template(options: Dict[str, Any]): ) ) else: - log.info( - "[modify_cert_template] Modifying template {template} ({description})".format( - template=template_id, - description=template.description, - ) - ) - templates_changed += 1 - if not options["dry_run"]: - template.template = new_template - template.save() - else: - log.info( - "DRY-RUN: Not making the following template change to {id}.".format( - id=template_id + if not dry_run: + log.info( + "[modify_cert_template] Modifying template {template} ({description})".format( + template=template_id, + description=template.description, + ) ) - ) - log.info( - "\n".join( - unified_diff( - template.template.splitlines(), - new_template.splitlines(), - lineterm="", - fromfile="old_template", - tofile="new_template", + template.template = new_template + templates_changed.append(template) + else: + log.info( + "DRY-RUN: Not making the following template change to {id}.".format( + id=template_id ) - ), - ) + ) + log.info( + "\n".join( + unified_diff( + template.template.splitlines(), + new_template.splitlines(), + lineterm="", + fromfile="old_template", + tofile="new_template", + ) + ), + ) log.info( - "[modify_cert_template] Modified {num} templates".format(num=templates_changed) + "[modify_cert_template] Modified {num} templates".format( + num=len(templates_changed) + ) ) - -def get_modified_template_text( - template_text: str, - old: str, - new: str, -): - """ - Returns the original template text with the first instance of `old` replaced with `new`. - Case-sensitive. - - Although this is a trivial method, it's factored into its own method to allow us to - write unit tests that can be easily modified if the testing algorithm is made more complex. - """ - return template_text.replace(old, new, 1) + return templates_changed diff --git a/lms/djangoapps/certificates/tests/test_tasks.py b/lms/djangoapps/certificates/tests/test_tasks.py index c02c5f1dbf8d..8cf8150235df 100644 --- a/lms/djangoapps/certificates/tests/test_tasks.py +++ b/lms/djangoapps/certificates/tests/test_tasks.py @@ -16,7 +16,7 @@ from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tasks import ( generate_certificate, - handle_modify_cert_template, + get_changed_cert_templates, ) from lms.djangoapps.certificates.tests.factories import CertificateTemplateFactory @@ -108,52 +108,59 @@ def test_generation_custom(self): class ModifyCertTemplateTests(TestCase): - """Tests for handle_modify_cert_template""" - - # FIXME: put in mocks for .get and .save - def setUp(self): - super().setUp() - self.cert1 = CertificateTemplateFactory - self.cert2 = CertificateTemplateFactory - self.cert3 = CertificateTemplateFactory + """Tests for get_changed_cert_templates""" def test_command_changes_called_templates(self): """Verify command changes for all and only those templates for which it is called.""" - self.cert1.template = "fiddledee-doo fiddledee-dah" - self.cert2.template = "violadee-doo violadee-dah" - self.cert3.template = "fiddledee-doo fiddledee-dah" + self.template1 = CertificateTemplateFactory.create( + template="fiddledee-doo fiddledee-dah" + ) + self.template2 = CertificateTemplateFactory.create( + template="violadee-doo violadee-dah" + ) + self.template3 = CertificateTemplateFactory.create( + template="fiddledee-doo fiddledee-dah" + ) + self.template1.save() + self.template2.save() + self.template3.save() expected1 = "fiddleeep-doo fiddledee-dah" expected2 = "violaeep-doo violadee-dah" - expected3 = "fiddledee-doo fiddledee-dah" options = { "old_text": "dee", "new_text": "eep", "templates": [1, 2], } - handle_modify_cert_template(options) - assert self.cert1.template == expected1 - assert self.cert2.template == expected2 - assert self.cert3.template == expected3 + new_templates = get_changed_cert_templates(options) + assert len(new_templates) == 2 + assert new_templates[0].template == expected1 + assert new_templates[1].template == expected2 def test_dry_run(self): """Verify command doesn't change anything on dry-run.""" - self.cert1.template = "fiddledee-doo fiddledee-dah" - self.cert2.template = "violadee-doo violadee-dah" - expected1 = "fiddledee-doo fiddledee-dah" - expected2 = "violadee-doo violadee-dah" + self.template1 = CertificateTemplateFactory.create( + template="fiddledee-doo fiddledee-dah" + ) + self.template2 = CertificateTemplateFactory.create( + template="violadee-doo violadee-dah" + ) + self.template1.save() + self.template2.save() options = { "old_text": "dee", "new_text": "eep", "templates": [1, 2], "dry_run": True, } - handle_modify_cert_template(options) - assert self.cert1.template == expected1 - assert self.cert2.template == expected2 + new_templates = get_changed_cert_templates(options) + assert new_templates == [] def test_multiline_change(self): """Verify template change works with a multiline change string.""" - self.cert1.template = "fiddledee-doo fiddledee-dah" + self.template1 = CertificateTemplateFactory.create( + template="fiddledee-doo fiddledee-dah" + ) + self.template1.save() new_text = """ there's something happening here what it is ain't exactly clear @@ -164,5 +171,6 @@ def test_multiline_change(self): "new_text": dedent(new_text), "templates": [1], } - handle_modify_cert_template(options) - assert self.cert1.template == expected + new_templates = get_changed_cert_templates(options) + assert len(new_templates) == 1 + assert new_templates[0].template == expected From e0ef013f022b281af7fbb3959d873241456551c6 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Wed, 17 Jan 2024 22:00:30 +0000 Subject: [PATCH 08/10] feat: fixing some codestyle/lint errors not sure why the manual linter didn't complain FIXES: APER-2851 --- .../tests/test_modify_certs_template.py | 2 +- .../certificates/tests/test_tasks.py | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py index c6ac0d2c903a..6e7284b8f8ed 100644 --- a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py +++ b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py @@ -33,7 +33,7 @@ def test_command_with_missing_param_new_text(self): "modify_cert_template", "--old-text", "blah", "--templates", "1 2 3" ) - def test_command_with_missing_param_old_text(self): + def test_command_with_missing_param_templates(self): """Verify command with a missing param --templates.""" with pytest.raises( CommandError, diff --git a/lms/djangoapps/certificates/tests/test_tasks.py b/lms/djangoapps/certificates/tests/test_tasks.py index 8cf8150235df..2e6c84464936 100644 --- a/lms/djangoapps/certificates/tests/test_tasks.py +++ b/lms/djangoapps/certificates/tests/test_tasks.py @@ -112,18 +112,18 @@ class ModifyCertTemplateTests(TestCase): def test_command_changes_called_templates(self): """Verify command changes for all and only those templates for which it is called.""" - self.template1 = CertificateTemplateFactory.create( + template1 = CertificateTemplateFactory.create( template="fiddledee-doo fiddledee-dah" ) - self.template2 = CertificateTemplateFactory.create( + template2 = CertificateTemplateFactory.create( template="violadee-doo violadee-dah" ) - self.template3 = CertificateTemplateFactory.create( + template3 = CertificateTemplateFactory.create( template="fiddledee-doo fiddledee-dah" ) - self.template1.save() - self.template2.save() - self.template3.save() + template1.save() + template2.save() + template3.save() expected1 = "fiddleeep-doo fiddledee-dah" expected2 = "violaeep-doo violadee-dah" options = { @@ -138,14 +138,14 @@ def test_command_changes_called_templates(self): def test_dry_run(self): """Verify command doesn't change anything on dry-run.""" - self.template1 = CertificateTemplateFactory.create( + template1 = CertificateTemplateFactory.create( template="fiddledee-doo fiddledee-dah" ) - self.template2 = CertificateTemplateFactory.create( + template2 = CertificateTemplateFactory.create( template="violadee-doo violadee-dah" ) - self.template1.save() - self.template2.save() + template1.save() + template2.save() options = { "old_text": "dee", "new_text": "eep", @@ -153,14 +153,14 @@ def test_dry_run(self): "dry_run": True, } new_templates = get_changed_cert_templates(options) - assert new_templates == [] + assert not new_templates def test_multiline_change(self): """Verify template change works with a multiline change string.""" - self.template1 = CertificateTemplateFactory.create( + template1 = CertificateTemplateFactory.create( template="fiddledee-doo fiddledee-dah" ) - self.template1.save() + template1.save() new_text = """ there's something happening here what it is ain't exactly clear From a35a6f908e9957d8fe5b753d8b963ac9fd046501 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 18 Jan 2024 13:49:22 +0000 Subject: [PATCH 09/10] feat: linter Fixing a linter air FIXES: APER-2851 --- .../management/commands/tests/test_modify_certs_template.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py index 6e7284b8f8ed..03d9b05d5413 100644 --- a/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py +++ b/lms/djangoapps/certificates/management/commands/tests/test_modify_certs_template.py @@ -4,10 +4,7 @@ import pytest from django.core.management import CommandError, call_command -from django.test import ( - TestCase, - override_settings, -) # lint-amnesty, pylint: disable=unused-import +from django.test import TestCase class ModifyCertTemplateTests(TestCase): From b4bf07660e90e960b4e562bc8286ae68bd212b90 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Thu, 18 Jan 2024 15:14:24 +0000 Subject: [PATCH 10/10] feat: fixes from code review improving a comment FIXES: APER-2851 --- .../certificates/management/commands/modify_cert_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/certificates/management/commands/modify_cert_template.py b/lms/djangoapps/certificates/management/commands/modify_cert_template.py index d720c628e6d6..f4ae5f64d2b6 100644 --- a/lms/djangoapps/certificates/management/commands/modify_cert_template.py +++ b/lms/djangoapps/certificates/management/commands/modify_cert_template.py @@ -16,7 +16,7 @@ class Command(BaseCommand): """Management command to modify certificate templates. Example usage: - ./manage.py lms modify_cert_template --old-text + ./manage.py lms modify_cert_template --old-text "" --new text "

boo!

" --templates 867 3509 """ help = """Modify one or more certificate templates.