diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e57c142d0..000000000 --- a/Dockerfile +++ /dev/null @@ -1,111 +0,0 @@ -FROM ubuntu:focal as base - -# System requirements -# - git; Used to pull in particular requirements from github rather than pypi, -# and to check the sha of the code checkout. -# - language-pack-en locales; ubuntu locale support so that system utilities have a consistent -# language and time zone. -# - python; ubuntu doesnt ship with python, so this is the python we will use to run the application -# - python3-pip; install pip to install application requirements.txt files -# - libssl-dev; # mysqlclient wont install without this. -# - libmysqlclient-dev; to install header files needed to use native C implementation for -# MySQL-python for performance gains. -# - wget; to download a watchman binary archive -# - unzip; to unzip a watchman binary archive -# - pkg-config; mysqlclient>=2.2.0 requires pkg-config (https://github.com/PyMySQL/mysqlclient/issues/620) - -# If you add a package here please include a comment above describing what it is used for -RUN apt-get update && \ - apt-get install -y software-properties-common && \ - apt-add-repository -y ppa:deadsnakes/ppa && apt-get update && \ - apt-get upgrade -qy && apt-get install language-pack-en locales gettext git \ - python3.11-dev python3.11-venv libmysqlclient-dev libssl-dev build-essential wget unzip pkg-config -qy && \ - rm -rf /var/lib/apt/lists/* - -# Create Python env -ENV VIRTUAL_ENV=/edx/app/credentials/venvs/credentials -RUN python3.11 -m venv $VIRTUAL_ENV -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - -# Create Node env -RUN pip install nodeenv -ENV NODE_ENV=/edx/app/credentials/nodeenvs/credentials -RUN nodeenv $NODE_ENV --node=18.17.1 --prebuilt -ENV PATH="$NODE_ENV/bin:$PATH" -RUN npm install -g npm@9.x.x - -RUN locale-gen en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 -ENV DJANGO_SETTINGS_MODULE credentials.settings.production -ENV OPENEDX_ATLAS_PULL true -ENV CREDENTIALS_CFG "minimal.yml" - -EXPOSE 18150 -RUN useradd -m --shell /bin/false app - -# Install watchman -RUN wget https://github.com/facebook/watchman/releases/download/v2023.11.20.00/watchman-v2023.11.20.00-linux.zip -RUN unzip watchman-v2023.11.20.00-linux.zip -RUN mkdir -p /usr/local/{bin,lib} /usr/local/var/run/watchman -RUN cp watchman-v2023.11.20.00-linux/bin/* /usr/local/bin -RUN cp watchman-v2023.11.20.00-linux/lib/* /usr/local/lib -RUN chmod 755 /usr/local/bin/watchman -RUN chmod 2777 /usr/local/var/run/watchman - -# Now install credentials -WORKDIR /edx/app/credentials/credentials - -# Copy the requirements explicitly even though we copy everything below -# this prevents the image cache from busting unless the dependencies have changed. -COPY requirements/production.txt /edx/app/credentials/credentials/requirements/production.txt -COPY requirements/pip_tools.txt /edx/app/credentials/credentials/requirements/pip_tools.txt - -# Dependencies are installed as root so they cannot be modified by the application user. -RUN pip install -r requirements/pip_tools.txt -RUN pip install -r requirements/production.txt - -RUN mkdir -p /edx/var/log - -# This line is after the python requirements so that changes to the code will not -# bust the image cache -COPY . /edx/app/credentials/credentials - -# Fetch the translations into the image once the Makefile's in place -RUN make pull_translations - -# Install dependencies in node_modules directory -RUN npm install --no-save -ENV NODE_BIN=/edx/app/credentials/credentials/node_modules -ENV PATH="$NODE_BIN/.bin:$PATH" -# Run webpack -RUN webpack --config webpack.config.js - -# Change static folder owner to application user. -RUN chown -R app:app /edx/app/credentials/credentials/credentials/static - -# Code is owned by root so it cannot be modified by the application user. -# So we copy it before changing users. -USER app - -# Gunicorn 19 does not log to stdout or stderr by default. Once we are past gunicorn 19, the logging to STDOUT need not be specified. -CMD gunicorn --workers=2 --name credentials -c /edx/app/credentials/credentials/credentials/docker_gunicorn_configuration.py --log-file - --max-requests=1000 credentials.wsgi:application - -# We don't switch back to the app user for devstack because we need devstack users to be -# able to update requirements and generally run things as root. -FROM base as dev -USER root -ENV DJANGO_SETTINGS_MODULE credentials.settings.devstack -RUN pip install -r /edx/app/credentials/credentials/requirements/dev.txt -RUN make pull_translations - -# Temporary compatibility hack while devstack is supporting -# both the old `edxops/credentials` image and this image: -# Add in a dummy ../credentials_env file. -# The credentials_env file was originally needed for sourcing to get -# environment variables like DJANGO_SETTINGS_MODULE, but now we just set -# those variables right in the Dockerfile. -RUN touch ../credentials_env - -CMD while true; do python ./manage.py runserver 0.0.0.0:18150; sleep 2; done diff --git a/credentials/apps/credentials/admin.py b/credentials/apps/credentials/admin.py index 0235abee2..bc7cf39bb 100644 --- a/credentials/apps/credentials/admin.py +++ b/credentials/apps/credentials/admin.py @@ -1,3 +1,4 @@ +from config_models.admin import ConfigurationModelAdmin from django.contrib import admin from django.db.models import Q @@ -6,6 +7,7 @@ CourseCertificate, ProgramCertificate, ProgramCompletionEmailConfiguration, + RevokeCertificatesConfig, Signatory, UserCredential, UserCredentialAttribute, @@ -113,3 +115,8 @@ class SignatoryAdmin(TimeStampedModelAdminMixin, admin.ModelAdmin): class ProgramCompletionEmailConfigurationAdmin(TimeStampedModelAdminMixin, admin.ModelAdmin): list_display = ("identifier", "enabled") search_fields = ("identifier",) + + +@admin.register(RevokeCertificatesConfig) +class RevokeCertificatesConfigAdmin(ConfigurationModelAdmin): + pass diff --git a/credentials/apps/credentials/management/commands/revoke_certificates.py b/credentials/apps/credentials/management/commands/revoke_certificates.py new file mode 100644 index 000000000..c49564811 --- /dev/null +++ b/credentials/apps/credentials/management/commands/revoke_certificates.py @@ -0,0 +1,153 @@ +"""Management command to revoke certificates given a certificate ID and a list of users""" + +import logging +import shlex +from typing import TYPE_CHECKING, Any + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from credentials.apps.credentials.models import RevokeCertificatesConfig, UserCredential + + +if TYPE_CHECKING: + from argparse import ArgumentParser + + from django.db.models import QuerySet + + +logger = logging.getLogger(__name__) +User = get_user_model() + + +class Command(BaseCommand): + """ + Management command to revoke certificates. + + Given a certificate ID and a list of users, revoke that certificate ID + for those users. + + Example usage: + + $ ./manage.py revoke_certificates --lms_user_ids 867 5309 925 --credential_id 90210 + """ + + help = "Revoke certificates for a list of LMS user IDs. Defaults to program certificates." + + def add_arguments(self, parser: "ArgumentParser") -> None: + """Arguments for the command.""" + parser.add_argument( + "--dry-run", + action="store_true", + help="Just show a preview of what would happen.", + ) + parser.add_argument( + "--args-from-database", + action="store_true", + help="Use arguments from the RevokeCertificates model instead of the command line.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="log each update", + ) + parser.add_argument( + "--lms_user_ids", + default=None, + nargs="+", + help="Users for whom this certificate should be revoked. Required.", + ) + parser.add_argument( + "--credential_id", + default=None, + help="ID of the certificate to be revoked. Required.", + ) + parser.add_argument( + "--credential_type", + default="programcertificate", + choices=["coursecertificate", "programcertificate", "credlybadgetemplate"], + help="Type of credential to revoke. Defaults to 'programcertificate'", + ) + + def get_usernames_from_lms_user_ids(self, lms_user_ids: list[str]) -> "QuerySet": + """ + Generate Users from a list of usernames from a list of user IDs + + Because a UserCredential stores a username, not a foreign key, it's most + efficient to convert the list of user IDs to users directly, before + starting the query. Returning a QuerySet of the User objects (instead of + usernames) allows us to do verbose logging and error reporting. + + Arguments: + + lms_user_ids: list(str): a list of LMS user IDs + + Returns: + + a QuerySet of User objects. + """ + users = User.objects.filter(lms_user_id__in=lms_user_ids) + missing_users = set(lms_user_ids).difference({str(i.lms_user_id) for i in users}) + if missing_users: + logger.warning(f"The following user IDs don't match existing users: {missing_users}") + return users + + def get_args_from_database(self) -> dict[str, Any]: + """Returns an options dictionary from the current NotifyCredentialsConfig model.""" + config = RevokeCertificatesConfig.current() + if not config.enabled: + raise CommandError("RevokeCertificatesConfig is disabled, but --args-from-database was requested.") + + argv = shlex.split(config.arguments) + parser = self.create_parser("manage.py", "revoke_certificates") + return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object + + def handle(self, *args, **options): + if options["args_from_database"]: + options = self.get_args_from_database() + credential_id = options.get("credential_id") + verbosity = options.get("verbose") + credential_type = options.get("credential_type") + dry_run = options.get("dry_run") + lms_user_ids = options.get("lms_user_ids") + + logger.info( + f"revoke_certificates starting, dry-run={dry_run}, credential_id={credential_id}, " + f"credential_type={credential_type}, lms_user_ids={lms_user_ids}, verbosity={verbosity}" + ) + + # Because we allow args_from_database, we cannot rely on marking arguments as required, + # so we validate our arguments here. + if not credential_id: + raise CommandError("You must specify a credential_id") + if not lms_user_ids: + raise CommandError("You must specify list of lms_user_ids") + users = self.get_usernames_from_lms_user_ids(lms_user_ids) + if not users: + raise CommandError("None of the given lms_user_ids maps to a real user") + + # We use usernames here, not foreign keys, so just make a list. + # This is not going to be a huge set of users, run from a management command. + usernames = [i.username for i in users] # type: list[str] + + user_creds_to_revoke = UserCredential.objects.filter( + username__in=usernames, + status=UserCredential.AWARDED, + credential_content_type__model=credential_type, + credential_id=credential_id, + ) + if not user_creds_to_revoke: + raise CommandError("No active certificates match the given criteria") + + # as a manually input list, this should be small enough to do in a single bulk_update + for user_cred in user_creds_to_revoke: + if verbosity: + # It's not worth doing an extra query to annotate the verbose logging message with + # user ID, and username isn't PII safe. If the person reading the logs wants more + # info about the affected users, this log message includes enough to look them up. + logger.info(f"Revoking UserCredential {user_cred.id} ({credential_type} {credential_id})") + user_cred.status = UserCredential.REVOKED + if not dry_run: + user_creds_to_revoke.bulk_update(user_creds_to_revoke, ["status"]) + + logger.info("Done revoking certificates") diff --git a/credentials/apps/credentials/management/commands/tests/test_revoke_certificates.py b/credentials/apps/credentials/management/commands/tests/test_revoke_certificates.py new file mode 100644 index 000000000..ba86e0592 --- /dev/null +++ b/credentials/apps/credentials/management/commands/tests/test_revoke_certificates.py @@ -0,0 +1,242 @@ +""" +Tests for the revoke_certificates management command +""" + +from unittest import mock + +from django.contrib.contenttypes.models import ContentType +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + +from credentials.apps.catalog.tests.factories import ( + CourseFactory, + CourseRunFactory, + OrganizationFactory, + ProgramFactory, +) +from credentials.apps.core.tests.factories import UserFactory +from credentials.apps.core.tests.mixins import SiteMixin +from credentials.apps.credentials.management.commands.revoke_certificates import Command +from credentials.apps.credentials.models import RevokeCertificatesConfig, UserCredential +from credentials.apps.credentials.tests.factories import ( + CourseCertificateFactory, + ProgramCertificateFactory, + UserCredentialFactory, +) + + +class RevokeCertificatesTests(SiteMixin, TestCase): + def setUp(self): + """ + Create several users with multiple UserCredentials in order to verify + bulk operations. + """ + super().setUp() + self.users = UserFactory.create_batch(3) + + # Set up org, courses, and certificate configs + self.orgs = [OrganizationFactory.create(name=name, site=self.site) for name in ["TestOrg1", "TestOrg2"]] + self.course_credential_content_type = ContentType.objects.get( + app_label="credentials", model="coursecertificate" + ) + self.program_credential_content_type = ContentType.objects.get( + app_label="credentials", model="programcertificate" + ) + + self.course = CourseFactory.create(site=self.site) + self.course_runs = CourseRunFactory.create_batch(2, course=self.course) + self.course_certs = [ + CourseCertificateFactory.create( + course_id=course_run.key, + course_run=course_run, + site=self.site, + ) + for course_run in self.course_runs + ] + + self.program = ProgramFactory( + title="TestProgram1", course_runs=self.course_runs, authoring_organizations=self.orgs, site=self.site + ) + self.program_cert = ProgramCertificateFactory.create(program_uuid=self.program.uuid, site=self.site) + + # Set up course and program UserCredentials for each test user + for user in self.users: + UserCredentialFactory.create( + username=user.username, + credential_content_type=self.program_credential_content_type, + credential=self.program_cert, + ) + for course_cert in self.course_certs: + UserCredentialFactory.create( + username=user.username, + credential_content_type=self.course_credential_content_type, + credential=course_cert, + ) + + def test_default_behavior_deletes_progam_certs(self): + """verify default behavior revokes expected and ONLY expected program certificates""" + # pick a subset of users to revoke + users_to_revoke = self.users[:2] + + call_command( + Command(), + "--lms_user_ids", + users_to_revoke[0].lms_user_id, + users_to_revoke[1].lms_user_id, + f"--credential_id={self.program_cert.id}", + ) + + revoked_creds = list(UserCredential.objects.filter(status=UserCredential.REVOKED)) + expected_revoked_creds = list( + UserCredential.objects.filter( + username__in=[u.username for u in users_to_revoke], + credential_content_type__model="programcertificate", + ) + ) + + self.assertListEqual(expected_revoked_creds, revoked_creds) + + def test_credential_type_specify(self): + """verify credential type can be specified""" + # pick a subset of users to revoke + users_to_revoke = self.users[:2] + cred_type_to_revoke = "coursecertificate" + cred_to_revoke = self.course_certs[0] + + call_command( + Command(), + "--lms_user_ids", + users_to_revoke[0].lms_user_id, + users_to_revoke[1].lms_user_id, + f"--credential_id={cred_to_revoke.id}", + f"--credential_type={cred_type_to_revoke}", + ) + + revoked_creds = list(UserCredential.objects.filter(status=UserCredential.REVOKED)) + expected_revoked_creds = list( + UserCredential.objects.filter( + username__in=[u.username for u in users_to_revoke], + credential_content_type__model=cred_type_to_revoke, + credential_id=cred_to_revoke.id, + ) + ) + self.assertListEqual(expected_revoked_creds, revoked_creds) + + def test_dry_run(self): + """verify dry_run makes no changes""" + # pick a subset of users to revoke + users_to_revoke = self.users[:2] + + call_command( + Command(), + "--lms_user_ids", + users_to_revoke[0].lms_user_id, + users_to_revoke[1].lms_user_id, + f"--credential_id={self.program_cert.id}", + "--dry-run", + ) + + revoked_creds = list(UserCredential.objects.filter(status=UserCredential.REVOKED)) + + self.assertFalse(revoked_creds) + + def test_verbosity_enabled(self): + """verify the verbose flag works when enabled""" + # pick a subset of users to revoke + users_to_revoke = self.users[:2] + expected_substring = "Revoking UserCredential" + + with self.assertLogs(level="INFO") as cm: + call_command( + Command(), + "--lms_user_ids", + users_to_revoke[0].lms_user_id, + users_to_revoke[1].lms_user_id, + f"--credential_id={self.program_cert.id}", + "--verbose", + ) + self.assertTrue(any(expected_substring in s for s in cm.output)) + + def test_verbosity_disabled(self): + """verify the verbose flag's absence works as expected""" + # pick a subset of users to revoke + users_to_revoke = self.users[:2] + expected_substring = "Revoking UserCredential" + + with self.assertLogs(level="INFO") as cm: + call_command( + Command(), + "--lms_user_ids", + users_to_revoke[0].lms_user_id, + users_to_revoke[1].lms_user_id, + f"--credential_id={self.program_cert.id}", + ) + self.assertFalse(any(expected_substring in s for s in cm.output)) + + def test_invalid_users(self): + """verify that the inclusion of invalid user IDs results in a warning""" + # pick a subset of users to revoke + users_to_revoke = self.users[:2] + expected_substring = "The following user IDs don't match existing users" + + with self.assertLogs(level="WARNING") as cm: + call_command( + Command(), + "--lms_user_ids", + users_to_revoke[0].lms_user_id, + users_to_revoke[1].lms_user_id, + 8675309, + f"--credential_id={self.program_cert.id}", + "--verbose", + ) + self.assertTrue(any(expected_substring in s for s in cm.output)) + + def test_validation(self): + """Test that the input validators are correct""" + # fake user + expected = "None of the given lms_user_ids maps to a real user*" + with self.assertRaisesRegex(CommandError, expected): + call_command(Command(), "--lms_user_ids", "8675309", "--credential_id", "123") + + # real user, doesn't mapped to a real credential + expected = "No active certificates match the given criteria" + with self.assertRaisesRegex(CommandError, expected): + call_command(Command(), "--lms_user_ids", self.users[0].lms_user_id, "--credential_id", "123") + + # missing credential_id + expected = "You must specify a credential_id" + with self.assertRaisesRegex(CommandError, expected): + call_command(Command(), "--lms_user_ids", self.users[0].lms_user_id) + + # missing lms_user_ids + expected = "You must specify list of lms_user_ids" + with self.assertRaisesRegex(CommandError, expected): + call_command(Command(), "--credential_id", "123") + + def test_args_from_database(self): + """Correctly parse args from database at the correct times""" + # Nothing in the database, should default to disabled + with self.assertRaisesRegex(CommandError, "RevokeCertificatesConfig is disabled.*"): + call_command(Command(), "--lms_user_ids", "8675309", "--credential_id", "123", "--args-from-database") + + # Add a config + config = RevokeCertificatesConfig.current() + config.arguments = f"--lms_user_ids {self.users[0].lms_user_id} --credential_id 90210" + config.enabled = True + config.save() + + # Not told to use config, should ignore it + with self.assertRaisesRegex(CommandError, "None of the given lms_user_ids maps to a real user*"): + call_command(Command(), "--lms_user_ids", "8675309", "--credential_id", "123") + + # Told to use it, and enabled. Should use config in preference of command line + with self.assertRaisesRegex(CommandError, "No active certificates match the given criteria"): + call_command(Command(), "--lms_user_ids", "8675309", "--credential_id", "123", "--args-from-database") + + config.enabled = False + config.save() + + # Explicitly disabled + with self.assertRaisesRegex(CommandError, "RevokeCertificatesConfig is disabled.*"): + call_command(Command(), "--lms_user_ids", "8675309", "--credential_id", "123", "--args-from-database") diff --git a/credentials/apps/credentials/migrations/0030_revoke_certificates_management_command.py b/credentials/apps/credentials/migrations/0030_revoke_certificates_management_command.py new file mode 100644 index 000000000..70c41cf38 --- /dev/null +++ b/credentials/apps/credentials/migrations/0030_revoke_certificates_management_command.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.16 on 2024-12-04 20:44 + +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), + ("credentials", "0029_alter_usercredential_credential_content_type"), + ] + + operations = [ + migrations.CreateModel( + name="RevokeCertificatesConfig", + 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 a management command, eg. "--certificate_id 222 --lms_user_ids 867 5309 925".', + ), + ), + ( + "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": "revoke_certificates argument", + }, + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index 0f6fd98bf..85e1dac9e 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING import bleach +from config_models.models import ConfigurationModel from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType @@ -427,3 +428,24 @@ class UserCredentialDateOverride(TimeStampedModel): date = models.DateTimeField( help_text="The date to override a course certificate with. This is set in the LMS Django Admin.", ) + + +class RevokeCertificatesConfig(ConfigurationModel): + """ + Manages configuration for a run of the revoke_certificates management command. + + .. no_pii: + """ + + class Meta: + app_label = "credentials" + verbose_name = "revoke_certificates argument" + + arguments = models.TextField( + blank=True, + help_text='Arguments for a management command, eg. "--certificate_id 222 --lms_user_ids 867 5309 925".', + default="", + ) + + def __str__(self): + return str(self.arguments) diff --git a/credentials/settings/base.py b/credentials/settings/base.py index 3a344d559..6fdbbde9e 100644 --- a/credentials/settings/base.py +++ b/credentials/settings/base.py @@ -42,6 +42,8 @@ "django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", + # Database-backed configuration + "config_models", ] THIRD_PARTY_APPS = [ @@ -404,7 +406,8 @@ ACE_CHANNEL_SAILTHRU_TEMPLATE_NAME = "" # unused, but required to be set or we see an exception # Set up logging for development use (logging to stdout) -LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel="DEBUG") +LOGGING_FORMAT_STRING = os.environ.get("LOGGING_FORMAT_STRING", "") +LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel="DEBUG", format_string=LOGGING_FORMAT_STRING) # DRF Settings REST_FRAMEWORK = { diff --git a/credentials/settings/devstack.py b/credentials/settings/devstack.py index 0f54b7ecf..a36b5939e 100644 --- a/credentials/settings/devstack.py +++ b/credentials/settings/devstack.py @@ -8,7 +8,8 @@ ALLOWED_HOSTS = ["*"] -LOGGING = get_logger_config(debug=True, dev_env=True, local_loglevel="DEBUG") +LOGGING_FORMAT_STRING = os.environ.get("LOGGING_FORMAT_STRING", "") +LOGGING = get_logger_config(debug=True, dev_env=True, local_loglevel="DEBUG", format_string=LOGGING_FORMAT_STRING) del LOGGING["handlers"]["local"] SECRET_KEY = os.environ.get("SECRET_KEY", "change-me") diff --git a/credentials/settings/local.py b/credentials/settings/local.py index 06101bdae..5df12a5fb 100644 --- a/credentials/settings/local.py +++ b/credentials/settings/local.py @@ -56,7 +56,8 @@ USER_CACHE_TTL = 60 # LOGGING -LOGGING = get_logger_config(debug=True, dev_env=True, local_loglevel="DEBUG") +LOGGING_FORMAT_STRING = os.environ.get("LOGGING_FORMAT_STRING", "") +LOGGING = get_logger_config(debug=True, dev_env=True, local_loglevel="DEBUG", format_string=LOGGING_FORMAT_STRING) ##################################################################### # Lastly, see if the developer has any local overrides. diff --git a/credentials/settings/production.py b/credentials/settings/production.py index 43c3e92cd..8b3ad8828 100644 --- a/credentials/settings/production.py +++ b/credentials/settings/production.py @@ -12,9 +12,6 @@ ALLOWED_HOSTS = ["*"] -LOGGING_FORMAT_STRING = "" -LOGGING = get_logger_config(format_string=LOGGING_FORMAT_STRING) - # Keep track of the names of settings that represent dicts. Instead of overriding the values in base.py, # the values read from disk should UPDATE the pre-configured dicts. DICT_UPDATE_KEYS = ("JWT_AUTH",) @@ -50,6 +47,9 @@ # Load the files storage backend settings for django storages vars().update(FILE_STORAGE_BACKEND) +# make sure this happens after the configuration file overrides so format string can be overridden +LOGGING = get_logger_config(format_string=LOGGING_FORMAT_STRING) + if "EXTRA_APPS" in locals(): INSTALLED_APPS += EXTRA_APPS diff --git a/credentials/settings/test.py b/credentials/settings/test.py index 3efd26246..71b55c701 100644 --- a/credentials/settings/test.py +++ b/credentials/settings/test.py @@ -10,7 +10,8 @@ "credentials.apps.edx_credentials_extensions", ] -LOGGING = get_logger_config(debug=False, dev_env=True, local_loglevel="DEBUG") +LOGGING_FORMAT_STRING = os.environ.get("LOGGING_FORMAT_STRING", "") +LOGGING = get_logger_config(debug=False, dev_env=True, local_loglevel="DEBUG", format_string=LOGGING_FORMAT_STRING) ALLOWED_HOSTS = ["*"] DATABASES = { diff --git a/package-lock.json b/package-lock.json index a941ca32f..128824883 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,16 +14,16 @@ "file-loader": "6.2.0", "mini-css-extract-plugin": "2.9.2", "sass": "1.82.0", - "sass-loader": "16.0.3", + "sass-loader": "16.0.4", "url-loader": "4.1.1", - "webpack": "5.97.0", + "webpack": "5.97.1", "webpack-bundle-tracker": "3.1.1" }, "devDependencies": { "@babel/core": "7.26.0", "@babel/eslint-parser": "7.25.9", "@babel/plugin-proposal-object-rest-spread": "7.20.7", - "@babel/plugin-transform-modules-commonjs": "7.25.9", + "@babel/plugin-transform-modules-commonjs": "7.26.3", "@babel/plugin-transform-object-assign": "7.25.9", "@babel/preset-env": "7.26.0", "@edx/eslint-config": "4.3.0", @@ -363,20 +363,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", - "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", @@ -1029,15 +1015,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", - "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-simple-access": "^7.25.9" + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2082,9 +2067,9 @@ } }, "node_modules/@openedx/paragon": { - "version": "22.10.0", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.10.0.tgz", - "integrity": "sha512-uwH/vN6PM9v77NIJ0MUyREdF+3LY/kXIVaOAN+TJKi6JexKoqM7jR30wGuI83YGymwthXDc8T4J54O/wXDoxrQ==", + "version": "22.11.0", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.11.0.tgz", + "integrity": "sha512-Bvh2o6ZeTLNtqYVr/ajKI29v//M/mEddaoOqjkzobE4JpS0muOJyQSEu2ju7dha90Sjjk/zy8Dr2enZ7phpkkQ==", "license": "Apache-2.0", "workspaces": [ "example", @@ -9816,9 +9801,9 @@ } }, "node_modules/sass-loader": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.3.tgz", - "integrity": "sha512-gosNorT1RCkuCMyihv6FBRR7BMV06oKRAs+l4UMp1mlcVg9rWN6KMmUj3igjQwmYys4mDP3etEYJgiHRbgHCHA==", + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", + "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", "license": "MIT", "dependencies": { "neo-async": "^2.6.2" @@ -11026,9 +11011,9 @@ } }, "node_modules/webpack": { - "version": "5.97.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.0.tgz", - "integrity": "sha512-CWT8v7ShSfj7tGs4TLRtaOLmOCPWhoKEvp+eA7FVx8Xrjb3XfT0aXdxDItnRZmE8sHcH+a8ayDrJCOjXKxVFfQ==", + "version": "5.97.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", + "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", diff --git a/package.json b/package.json index 94ad7bcf4..5e500dc39 100644 --- a/package.json +++ b/package.json @@ -16,16 +16,16 @@ "file-loader": "6.2.0", "mini-css-extract-plugin": "2.9.2", "sass": "1.82.0", - "sass-loader": "16.0.3", + "sass-loader": "16.0.4", "url-loader": "4.1.1", - "webpack": "5.97.0", + "webpack": "5.97.1", "webpack-bundle-tracker": "3.1.1" }, "devDependencies": { "@babel/core": "7.26.0", "@babel/eslint-parser": "7.25.9", "@babel/plugin-proposal-object-rest-spread": "7.20.7", - "@babel/plugin-transform-modules-commonjs": "7.25.9", + "@babel/plugin-transform-modules-commonjs": "7.26.3", "@babel/plugin-transform-object-assign": "7.25.9", "@babel/preset-env": "7.26.0", "@edx/eslint-config": "4.3.0", diff --git a/requirements/all.txt b/requirements/all.txt index 22b873387..d59c68d51 100644 --- a/requirements/all.txt +++ b/requirements/all.txt @@ -33,11 +33,11 @@ bleach==6.2.0 # via # -r requirements/dev.txt # -r requirements/production.txt -boto3==1.35.73 +boto3==1.35.76 # via # -r requirements/production.txt # django-ses -botocore==1.35.73 +botocore==1.35.76 # via # -r requirements/production.txt # boto3 @@ -135,12 +135,13 @@ distlib==0.3.9 # via # -r requirements/dev.txt # virtualenv -django==4.2.16 +django==4.2.17 # via # -c requirements/common_constraints.txt # -r requirements/dev.txt # -r requirements/production.txt # django-appconf + # django-config-models # django-cors-headers # django-crum # django-debug-toolbar @@ -177,6 +178,10 @@ django-appconf==1.0.6 # -r requirements/dev.txt # -r requirements/production.txt # django-statici18n +django-config-models==2.7.0 + # via + # -r requirements/dev.txt + # -r requirements/production.txt django-cors-headers==4.6.0 # via # -r requirements/dev.txt @@ -253,6 +258,7 @@ djangorestframework==3.15.2 # via # -r requirements/dev.txt # -r requirements/production.txt + # django-config-models # django-rest-swagger # drf-jwt # drf-yasg @@ -301,6 +307,7 @@ edx-django-utils==5.16.0 # -c requirements/constraints.txt # -r requirements/dev.txt # -r requirements/production.txt + # django-config-models # edx-ace # edx-drf-extensions # edx-event-bus-kafka @@ -411,7 +418,7 @@ google-cloud-firestore==2.19.0 # -r requirements/dev.txt # -r requirements/production.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via # -r requirements/dev.txt # -r requirements/production.txt @@ -492,13 +499,18 @@ jmespath==1.0.1 # -r requirements/production.txt # boto3 # botocore -lxml[html-clean]==5.1.1 +lxml[html-clean]==5.3.0 # via - # -c requirements/constraints.txt # -r requirements/dev.txt # -r requirements/production.txt # edx-credentials-themes # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.1 + # via + # -r requirements/dev.txt + # -r requirements/production.txt + # lxml markdown==3.7 # via # -r requirements/dev.txt @@ -607,7 +619,7 @@ proto-plus==1.25.0 # -r requirements/production.txt # google-api-core # google-cloud-firestore -protobuf==5.29.0 +protobuf==5.29.1 # via # -r requirements/dev.txt # -r requirements/production.txt @@ -799,7 +811,7 @@ simplejson==3.19.3 # -r requirements/production.txt # django-rest-swagger # sailthru-client -six==1.16.0 +six==1.17.0 # via # -r requirements/dev.txt # -r requirements/production.txt diff --git a/requirements/base.in b/requirements/base.in index 1fd279ca1..28bcd596a 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -13,6 +13,7 @@ bleach coreapi didkit django +django-config-models # Configuration models for Django allowing config management with auditing django-cors-headers django-extensions django-filter diff --git a/requirements/base.txt b/requirements/base.txt index 0df02af82..4966bfa2b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -51,11 +51,12 @@ defusedxml==0.8.0rc2 # social-auth-core didkit==0.3.3 # via -r requirements/base.in -django==4.2.16 +django==4.2.17 # via # -c requirements/common_constraints.txt # -r requirements/base.in # django-appconf + # django-config-models # django-cors-headers # django-crum # django-extensions @@ -85,6 +86,8 @@ django==4.2.16 # xss-utils django-appconf==1.0.6 # via django-statici18n +django-config-models==2.7.0 + # via -r requirements/base.in django-cors-headers==4.6.0 # via -r requirements/base.in django-crum==0.7.9 @@ -122,6 +125,7 @@ django-webpack-loader==3.1.1 djangorestframework==3.15.2 # via # -r requirements/base.in + # django-config-models # django-rest-swagger # drf-jwt # drf-yasg @@ -148,6 +152,7 @@ edx-django-utils==5.16.0 # via # -c requirements/constraints.txt # -r requirements/base.in + # django-config-models # edx-ace # edx-drf-extensions # edx-event-bus-kafka @@ -207,7 +212,7 @@ google-cloud-core==2.4.1 # google-cloud-storage google-cloud-firestore==2.19.0 # via firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via firebase-admin google-crc32c==1.6.0 # via @@ -239,11 +244,13 @@ jinja2==3.1.4 # via # code-annotations # coreschema -lxml[html-clean,html_clean]==5.1.1 +lxml[html-clean,html_clean]==5.3.0 # via - # -c requirements/constraints.txt # edx-credentials-themes # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.1 + # via lxml markdown==3.7 # via -r requirements/base.in markupsafe==3.0.2 @@ -283,7 +290,7 @@ proto-plus==1.25.0 # via # google-api-core # google-cloud-firestore -protobuf==5.29.0 +protobuf==5.29.1 # via # google-api-core # google-cloud-firestore @@ -370,7 +377,7 @@ simplejson==3.19.3 # via # django-rest-swagger # sailthru-client -six==1.16.0 +six==1.17.0 # via # edx-ace # edx-auth-backends diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 56ce69d9c..844ea2aa9 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -11,11 +11,6 @@ # Common constraints for edx repos -c common_constraints.txt -# Pinning lxml to < 5.2 as edx-i18n-tools package needs to be updated. -# Release notes: https://pypi.org/project/lxml/5.2.0/ -# Github issue: https://github.com/openedx/i18n-tools/issues/144 -lxml<5.2 - # Pinning edx-django-utils to <6 # v6 drops support for python versions <3.12 # Changelog: https://github.com/openedx/edx-django-utils/blob/master/CHANGELOG.rst#600---2024-10-09 diff --git a/requirements/dev.txt b/requirements/dev.txt index 18be4e96b..4d3ca1c99 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -108,11 +108,12 @@ distlib==0.3.9 # via # -r requirements/test.txt # virtualenv -django==4.2.16 +django==4.2.17 # via # -c requirements/common_constraints.txt # -r requirements/test.txt # django-appconf + # django-config-models # django-cors-headers # django-crum # django-debug-toolbar @@ -147,6 +148,8 @@ django-appconf==1.0.6 # via # -r requirements/test.txt # django-statici18n +django-config-models==2.7.0 + # via -r requirements/test.txt django-cors-headers==4.6.0 # via -r requirements/test.txt django-crum==0.7.9 @@ -193,6 +196,7 @@ django-webpack-loader==3.1.1 djangorestframework==3.15.2 # via # -r requirements/test.txt + # django-config-models # django-rest-swagger # drf-jwt # drf-yasg @@ -225,6 +229,7 @@ edx-django-utils==5.16.0 # via # -c requirements/constraints.txt # -r requirements/test.txt + # django-config-models # edx-ace # edx-drf-extensions # edx-event-bus-kafka @@ -313,7 +318,7 @@ google-cloud-firestore==2.19.0 # via # -r requirements/test.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via # -r requirements/test.txt # firebase-admin @@ -372,12 +377,16 @@ jinja2==3.1.4 # -r requirements/test.txt # code-annotations # coreschema -lxml[html-clean]==5.1.1 +lxml[html-clean]==5.3.0 # via - # -c requirements/constraints.txt # -r requirements/test.txt # edx-credentials-themes # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.1 + # via + # -r requirements/test.txt + # lxml markdown==3.7 # via -r requirements/test.txt markupsafe==3.0.2 @@ -464,7 +473,7 @@ proto-plus==1.25.0 # -r requirements/test.txt # google-api-core # google-cloud-firestore -protobuf==5.29.0 +protobuf==5.29.1 # via # -r requirements/test.txt # google-api-core @@ -620,7 +629,7 @@ simplejson==3.19.3 # -r requirements/test.txt # django-rest-swagger # sailthru-client -six==1.16.0 +six==1.17.0 # via # -r requirements/test.txt # edx-ace diff --git a/requirements/django.txt b/requirements/django.txt index 64aaf996f..ebf97308f 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==4.2.16 +django==4.2.17 diff --git a/requirements/production.txt b/requirements/production.txt index 63b7c21cd..042ec4e67 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -20,9 +20,9 @@ backoff==2.2.1 # segment-analytics-python bleach==6.2.0 # via -r requirements/base.txt -boto3==1.35.73 +boto3==1.35.76 # via django-ses -botocore==1.35.73 +botocore==1.35.76 # via # boto3 # s3transfer @@ -77,11 +77,12 @@ defusedxml==0.8.0rc2 # social-auth-core didkit==0.3.3 # via -r requirements/base.txt -django==4.2.16 +django==4.2.17 # via # -c requirements/common_constraints.txt # -r requirements/base.txt # django-appconf + # django-config-models # django-cors-headers # django-crum # django-extensions @@ -114,6 +115,8 @@ django-appconf==1.0.6 # via # -r requirements/base.txt # django-statici18n +django-config-models==2.7.0 + # via -r requirements/base.txt django-cors-headers==4.6.0 # via -r requirements/base.txt django-crum==0.7.9 @@ -156,6 +159,7 @@ django-webpack-loader==3.1.1 djangorestframework==3.15.2 # via # -r requirements/base.txt + # django-config-models # django-rest-swagger # drf-jwt # drf-yasg @@ -188,6 +192,7 @@ edx-django-utils==5.16.0 # via # -c requirements/constraints.txt # -r requirements/base.txt + # django-config-models # edx-ace # edx-drf-extensions # edx-event-bus-kafka @@ -264,7 +269,7 @@ google-cloud-firestore==2.19.0 # via # -r requirements/base.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via # -r requirements/base.txt # firebase-admin @@ -321,12 +326,16 @@ jmespath==1.0.1 # via # boto3 # botocore -lxml[html-clean]==5.1.1 +lxml[html-clean]==5.3.0 # via - # -c requirements/constraints.txt # -r requirements/base.txt # edx-credentials-themes # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.1 + # via + # -r requirements/base.txt + # lxml markdown==3.7 # via -r requirements/base.txt markupsafe==3.0.2 @@ -386,7 +395,7 @@ proto-plus==1.25.0 # -r requirements/base.txt # google-api-core # google-cloud-firestore -protobuf==5.29.0 +protobuf==5.29.1 # via # -r requirements/base.txt # google-api-core @@ -509,7 +518,7 @@ simplejson==3.19.3 # -r requirements/base.txt # django-rest-swagger # sailthru-client -six==1.16.0 +six==1.17.0 # via # -r requirements/base.txt # edx-ace diff --git a/requirements/test.txt b/requirements/test.txt index 66453cc43..e9f3b8264 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -101,6 +101,7 @@ distlib==0.3.9 # -c requirements/common_constraints.txt # -r requirements/base.txt # django-appconf + # django-config-models # django-cors-headers # django-crum # django-extensions @@ -132,6 +133,8 @@ django-appconf==1.0.6 # via # -r requirements/base.txt # django-statici18n +django-config-models==2.7.0 + # via -r requirements/base.txt django-cors-headers==4.6.0 # via -r requirements/base.txt django-crum==0.7.9 @@ -172,6 +175,7 @@ django-webpack-loader==3.1.1 djangorestframework==3.15.2 # via # -r requirements/base.txt + # django-config-models # django-rest-swagger # drf-jwt # drf-yasg @@ -204,6 +208,7 @@ edx-django-utils==5.16.0 # via # -c requirements/constraints.txt # -r requirements/base.txt + # django-config-models # edx-ace # edx-drf-extensions # edx-event-bus-kafka @@ -288,7 +293,7 @@ google-cloud-firestore==2.19.0 # via # -r requirements/base.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==2.19.0 # via # -r requirements/base.txt # firebase-admin @@ -345,12 +350,16 @@ jinja2==3.1.4 # -r requirements/base.txt # code-annotations # coreschema -lxml[html-clean]==5.1.1 +lxml[html-clean]==5.3.0 # via - # -c requirements/constraints.txt # -r requirements/base.txt # edx-credentials-themes # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.1 + # via + # -r requirements/base.txt + # lxml markdown==3.7 # via -r requirements/base.txt markupsafe==3.0.2 @@ -426,7 +435,7 @@ proto-plus==1.25.0 # -r requirements/base.txt # google-api-core # google-cloud-firestore -protobuf==5.29.0 +protobuf==5.29.1 # via # -r requirements/base.txt # google-api-core @@ -572,7 +581,7 @@ simplejson==3.19.3 # -r requirements/base.txt # django-rest-swagger # sailthru-client -six==1.16.0 +six==1.17.0 # via # -r requirements/base.txt # edx-ace diff --git a/requirements/translations.txt b/requirements/translations.txt index 57bd527c9..bafb98caa 100644 --- a/requirements/translations.txt +++ b/requirements/translations.txt @@ -6,16 +6,18 @@ # asgiref==3.8.1 # via django -django==4.2.16 +django==4.2.17 # via # -c requirements/common_constraints.txt # edx-i18n-tools edx-i18n-tools==1.6.3 # via -r requirements/translations.in -lxml[html-clean,html_clean]==5.1.1 +lxml[html-clean,html_clean]==5.3.0 # via - # -c requirements/constraints.txt # edx-i18n-tools + # lxml-html-clean +lxml-html-clean==0.4.1 + # via lxml path==16.16.0 # via edx-i18n-tools polib==1.2.0