From 551caa9f307b9f629ce6af3e978587c766f0f136 Mon Sep 17 00:00:00 2001 From: Alie Langston Date: Wed, 18 Oct 2023 10:30:36 -0400 Subject: [PATCH] feat: add management command for course prompts --- CHANGELOG.rst | 4 + learning_assistant/__init__.py | 2 +- learning_assistant/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/set_course_prompts.py | 109 ++++++++++++++++++ .../management/commands/tests/__init__.py | 0 .../commands/tests/test_set_course_prompts.py | 73 ++++++++++++ requirements/base.in | 1 + requirements/base.txt | 21 +++- requirements/ci.txt | 2 +- requirements/dev.txt | 22 +++- requirements/doc.txt | 53 +++++---- requirements/pip.txt | 2 +- requirements/quality.txt | 20 +++- requirements/test.txt | 21 +++- test_settings.py | 5 + 16 files changed, 290 insertions(+), 45 deletions(-) create mode 100644 learning_assistant/management/__init__.py create mode 100644 learning_assistant/management/commands/__init__.py create mode 100644 learning_assistant/management/commands/set_course_prompts.py create mode 100644 learning_assistant/management/commands/tests/__init__.py create mode 100644 learning_assistant/management/commands/tests/test_set_course_prompts.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6841ee5..9fdec11 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,10 @@ Change Log .. There should always be an "Unreleased" section for changes pending release. +1.5.0 - 2023-10-18 +****************** +* Add management command to generate course prompts + 1.4.0 - 2023-09-11 ****************** * Send reduced message list if needed to avoid going over token limit diff --git a/learning_assistant/__init__.py b/learning_assistant/__init__.py index 60ac235..d31819d 100644 --- a/learning_assistant/__init__.py +++ b/learning_assistant/__init__.py @@ -2,6 +2,6 @@ Plugin for a learning assistant backend, intended for use within edx-platform. """ -__version__ = '1.4.0' +__version__ = '1.5.0' default_app_config = 'learning_assistant.apps.LearningAssistantConfig' # pylint: disable=invalid-name diff --git a/learning_assistant/management/__init__.py b/learning_assistant/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning_assistant/management/commands/__init__.py b/learning_assistant/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning_assistant/management/commands/set_course_prompts.py b/learning_assistant/management/commands/set_course_prompts.py new file mode 100644 index 0000000..7173087 --- /dev/null +++ b/learning_assistant/management/commands/set_course_prompts.py @@ -0,0 +1,109 @@ +""" +Django management command to generate course prompts. +""" +import json +import logging +from posixpath import join as urljoin + +from django.conf import settings +from django.core.management.base import BaseCommand +from edx_rest_api_client.client import OAuthAPIClient +from opaque_keys.edx.keys import CourseKey + +try: + from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user +except ImportError: + create_jwt_for_user = None + +from learning_assistant.models import CoursePrompt + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django Management command to create a set of course prompts + """ + + def add_arguments(self, parser): + + # list of course ids + parser.add_argument( + '--course_ids', + dest='course_ids', + help='Comma separated list of course_ids to generate. Only newer style course ids can be supplied.', + ) + + # pre-message + parser.add_argument( + '--pre_message', + dest='pre_message', + help='Message to prepend to course topics', + ) + + parser.add_argument( + '--skills_descriptor', + dest='skills_descriptor', + help='Message that describes skill structure' + ) + + # post-message + parser.add_argument( + '--post_message', + dest='post_message', + help='Message to append to course topics', + ) + + @staticmethod + def _get_discovery_api_client(): + """ + Returns an API client which can be used to make Catalog API requests. + """ + return OAuthAPIClient( + base_url=settings.DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, + client_id=settings.DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_KEY, + client_secret=settings.DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_SECRET, + ) + + def handle(self, *args, **options): + """ + Management command entry point. + + This command is meant to generate a small (<500) set of course prompts. If a larger number of prompts + should be created, consider adding batching to this command. + """ + course_ids = options['course_ids'] + pre_message = options['pre_message'] + skills_descriptor = options['skills_descriptor'] + post_message = options['post_message'] + + client = self._get_discovery_api_client() + + course_ids_list = course_ids.split(',') + for course_run_id in course_ids_list: + course_key = CourseKey.from_string(course_run_id) + + # discovery API requires course keys, not course run keys + course_id = f'{course_key.org}+{course_key.course}' + + url = urljoin( + settings.DISCOVERY_BASE_URL, + 'api/v1/courses/{course_id}'.format(course_id=course_id) + ) + response_data = client.get(url).json() + title = response_data['title'] + skill_names = response_data['skill_names'] + + # create restructured dictionary with data + course_dict = {'title': title, 'topics': skill_names} + + # append descriptor message and decode json dict into a string + skills_message = skills_descriptor + json.dumps(course_dict) + + # finally, create list of prompt messages and save + prompt_messages = [pre_message, skills_message, post_message] + CoursePrompt.objects.update_or_create( + course_id=course_run_id, json_prompt_content=prompt_messages + ) + + logger.info('Updated course prompt for course_run_id=%s', course_run_id) diff --git a/learning_assistant/management/commands/tests/__init__.py b/learning_assistant/management/commands/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning_assistant/management/commands/tests/test_set_course_prompts.py b/learning_assistant/management/commands/tests/test_set_course_prompts.py new file mode 100644 index 0000000..acc5f9d --- /dev/null +++ b/learning_assistant/management/commands/tests/test_set_course_prompts.py @@ -0,0 +1,73 @@ +""" +Tests for the set_course_prompts management command. +""" +import json +from posixpath import join as urljoin +from unittest.mock import MagicMock, patch +from requests import Response + +from django.conf import settings +from django.core.management import call_command +from django.test import TestCase + +from learning_assistant.models import CoursePrompt + + +class SetCoursePromptsTests(TestCase): + """Test set_course_prompts command""" + command = 'set_course_prompts' + + def setUp(self): + self.pre_message = 'This is the first message' + self.skills_descriptor = 'These are the skills: ' + self.post_message = 'This message comes after' + self.course_ids = 'course-v1:edx+test+23,course-v1:edx+test+24' + self.course_title = 'Intro to Testing' + self.skill_names = ['Testing', 'Computers', 'Coding'] + + def get_mock_discovery_response(self): + """ + Create scaled down mock of discovery response + """ + response_data = { + 'title': self.course_title, + 'skill_names': self.skill_names + } + return response_data + + @patch('learning_assistant.management.commands.set_course_prompts.Command._get_discovery_api_client') + def test_course_prompts_created(self, mock_get_discovery_client): + """ + Assert that course prompts are created by calling management command. + """ + mock_client = MagicMock() + mock_get_discovery_client.return_value = mock_client + mock_client.get.return_value = MagicMock(status_code=200, json=lambda : self.get_mock_discovery_response()) + + call_command( + self.command, + course_ids=self.course_ids, + pre_message=self.pre_message, + skills_descriptor=self.skills_descriptor, + post_message=self.post_message, + ) + + # assert that discovery api was called with course id, not course run id + expected_url = urljoin( + settings.DISCOVERY_BASE_URL, + 'api/v1/courses/{course_id}'.format(course_id='edx+test') + ) + mock_client.get.assert_any_call(expected_url) + mock_client.get.assert_called() + + # assert that number of prompts created is equivalent to number of courses passed in to command + prompts = CoursePrompt.objects.filter() + self.assertEqual(len(prompts), len(self.course_ids.split(','))) + + # assert structure of prompt + course_prompt = prompts[0].json_prompt_content + self.assertEqual(len(course_prompt), 3) + + skills_message = self.skills_descriptor + json.dumps({'title': self.course_title, 'topics': self.skill_names}) + expected_response = [self.pre_message, skills_message, self.post_message] + self.assertEqual(course_prompt, expected_response) diff --git a/requirements/base.in b/requirements/base.in index b11b471..9f9bb7e 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -5,4 +5,5 @@ Django # Web application framework django-model-utils djangorestframework edx-drf-extensions +edx-rest-api-client edx-opaque-keys diff --git a/requirements/base.txt b/requirements/base.txt index 3c45598..aa583aa 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -45,20 +45,24 @@ djangorestframework==3.14.0 drf-jwt==1.19.2 # via edx-drf-extensions edx-django-utils==5.7.0 - # via edx-drf-extensions -edx-drf-extensions==8.10.0 + # via + # edx-drf-extensions + # edx-rest-api-client +edx-drf-extensions==8.12.0 # via -r requirements/base.in edx-opaque-keys==2.5.1 # via # -r requirements/base.in # edx-drf-extensions +edx-rest-api-client==5.6.1 + # via -r requirements/base.in idna==3.4 # via requests newrelic==9.1.0 # via edx-django-utils pbr==5.11.1 # via stevedore -psutil==5.9.5 +psutil==5.9.6 # via edx-django-utils pycparser==2.21 # via cffi @@ -66,6 +70,8 @@ pyjwt[crypto]==2.8.0 # via # drf-jwt # edx-drf-extensions + # edx-rest-api-client + # pyjwt pymongo==3.13.0 # via edx-opaque-keys pynacl==1.5.0 @@ -75,9 +81,14 @@ pytz==2023.3.post1 # django # djangorestframework requests==2.31.0 - # via edx-drf-extensions + # via + # edx-drf-extensions + # edx-rest-api-client + # slumber semantic-version==2.10.0 # via edx-drf-extensions +slumber==0.7.1 + # via edx-rest-api-client sqlparse==0.4.4 # via django stevedore==5.1.0 @@ -88,5 +99,5 @@ typing-extensions==4.8.0 # via # asgiref # edx-opaque-keys -urllib3==2.0.6 +urllib3==2.0.7 # via requests diff --git a/requirements/ci.txt b/requirements/ci.txt index ee3ed60..7f64397 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -41,7 +41,7 @@ tox==3.28.0 # tox-battery tox-battery==0.6.2 # via -r requirements/ci.in -urllib3==2.0.6 +urllib3==2.0.7 # via requests virtualenv==20.24.5 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 8ac027b..b2cd968 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -58,6 +58,7 @@ coverage[toml]==7.3.2 # -r requirements/ci.txt # -r requirements/quality.txt # codecov + # coverage # pytest-cov cryptography==41.0.4 # via @@ -65,7 +66,7 @@ cryptography==41.0.4 # pyjwt ddt==1.6.0 # via -r requirements/quality.txt -diff-cover==7.7.0 +diff-cover==8.0.0 # via -r requirements/dev.in dill==0.3.7 # via @@ -111,7 +112,8 @@ edx-django-utils==5.7.0 # via # -r requirements/quality.txt # edx-drf-extensions -edx-drf-extensions==8.10.0 + # edx-rest-api-client +edx-drf-extensions==8.12.0 # via -r requirements/quality.txt edx-i18n-tools==1.3.0 # via -r requirements/dev.in @@ -121,6 +123,8 @@ edx-opaque-keys==2.5.1 # via # -r requirements/quality.txt # edx-drf-extensions +edx-rest-api-client==5.6.1 + # via -r requirements/quality.txt exceptiongroup==1.1.3 # via # -r requirements/quality.txt @@ -201,7 +205,7 @@ pluggy==1.3.0 # tox polib==1.2.0 # via edx-i18n-tools -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/quality.txt # edx-django-utils @@ -209,7 +213,7 @@ py==1.11.0 # via # -r requirements/ci.txt # tox -pycodestyle==2.11.0 +pycodestyle==2.11.1 # via -r requirements/quality.txt pycparser==2.21 # via @@ -224,6 +228,8 @@ pyjwt[crypto]==2.8.0 # -r requirements/quality.txt # drf-jwt # edx-drf-extensions + # edx-rest-api-client + # pyjwt pylint==2.17.7 # via # -r requirements/quality.txt @@ -286,7 +292,9 @@ requests==2.31.0 # -r requirements/quality.txt # codecov # edx-drf-extensions + # edx-rest-api-client # responses + # slumber responses==0.23.3 # via -r requirements/quality.txt semantic-version==2.10.0 @@ -299,6 +307,10 @@ six==1.16.0 # -r requirements/quality.txt # edx-lint # tox +slumber==0.7.1 + # via + # -r requirements/quality.txt + # edx-rest-api-client snowballstemmer==2.2.0 # via # -r requirements/quality.txt @@ -351,7 +363,7 @@ typing-extensions==4.8.0 # astroid # edx-opaque-keys # pylint -urllib3==2.0.6 +urllib3==2.0.7 # via # -r requirements/ci.txt # -r requirements/quality.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index ee4e2ed..f7b3d20 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -37,12 +37,12 @@ code-annotations==1.5.0 coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==41.0.4 # via # -r requirements/test.txt # pyjwt - # secretstorage ddt==1.6.0 # via -r requirements/test.txt django==3.2.22 @@ -88,12 +88,15 @@ edx-django-utils==5.7.0 # via # -r requirements/test.txt # edx-drf-extensions -edx-drf-extensions==8.10.0 + # edx-rest-api-client +edx-drf-extensions==8.12.0 # via -r requirements/test.txt edx-opaque-keys==2.5.1 # via # -r requirements/test.txt # edx-drf-extensions +edx-rest-api-client==5.6.1 + # via -r requirements/test.txt exceptiongroup==1.1.3 # via # -r requirements/test.txt @@ -110,18 +113,12 @@ importlib-metadata==6.8.0 # keyring # sphinx # twine -importlib-resources==6.1.0 - # via keyring iniconfig==2.0.0 # via # -r requirements/test.txt # pytest jaraco-classes==3.3.0 # via keyring -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.2 # via # -r requirements/test.txt @@ -161,7 +158,7 @@ pluggy==1.3.0 # via # -r requirements/test.txt # pytest -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test.txt # edx-django-utils @@ -180,6 +177,8 @@ pyjwt[crypto]==2.8.0 # -r requirements/test.txt # drf-jwt # edx-drf-extensions + # edx-rest-api-client + # pyjwt pymongo==3.13.0 # via # -r requirements/test.txt @@ -206,7 +205,6 @@ python-slugify==8.0.1 pytz==2023.3.post1 # via # -r requirements/test.txt - # babel # django # djangorestframework pyyaml==6.0.1 @@ -220,8 +218,10 @@ requests==2.31.0 # via # -r requirements/test.txt # edx-drf-extensions + # edx-rest-api-client # requests-toolbelt # responses + # slumber # sphinx # twine requests-toolbelt==1.0.0 @@ -234,27 +234,35 @@ rfc3986==2.0.0 # via twine rich==13.6.0 # via twine -secretstorage==3.3.3 - # via keyring semantic-version==2.10.0 # via # -r requirements/test.txt # edx-drf-extensions +slumber==0.7.1 + # via + # -r requirements/test.txt + # edx-rest-api-client snowballstemmer==2.2.0 # via sphinx -sphinx==7.1.2 - # via -r requirements/doc.in -sphinxcontrib-applehelp==1.0.4 +sphinx==7.2.6 + # via + # -r requirements/doc.in + # sphinxcontrib-applehelp + # sphinxcontrib-devhelp + # sphinxcontrib-htmlhelp + # sphinxcontrib-qthelp + # sphinxcontrib-serializinghtml +sphinxcontrib-applehelp==1.0.7 # via sphinx -sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-devhelp==1.0.5 # via sphinx -sphinxcontrib-htmlhelp==2.0.1 +sphinxcontrib-htmlhelp==2.0.4 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-qthelp==1.0.6 # via sphinx -sphinxcontrib-serializinghtml==1.1.5 +sphinxcontrib-serializinghtml==1.1.9 # via sphinx sqlparse==0.4.4 # via @@ -290,14 +298,11 @@ typing-extensions==4.8.0 # -r requirements/test.txt # asgiref # edx-opaque-keys - # rich -urllib3==2.0.6 +urllib3==2.0.7 # via # -r requirements/test.txt # requests # responses # twine zipp==3.17.0 - # via - # importlib-metadata - # importlib-resources + # via importlib-metadata diff --git a/requirements/pip.txt b/requirements/pip.txt index 3e7d8f4..2154d29 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -8,7 +8,7 @@ wheel==0.41.2 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==23.2.1 +pip==23.3 # via -r requirements/pip.in setuptools==68.2.2 # via -r requirements/pip.in diff --git a/requirements/quality.txt b/requirements/quality.txt index d8b2473..4c7294b 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -41,6 +41,7 @@ code-annotations==1.5.0 coverage[toml]==7.3.2 # via # -r requirements/test.txt + # coverage # pytest-cov cryptography==41.0.4 # via @@ -85,7 +86,8 @@ edx-django-utils==5.7.0 # via # -r requirements/test.txt # edx-drf-extensions -edx-drf-extensions==8.10.0 + # edx-rest-api-client +edx-drf-extensions==8.12.0 # via -r requirements/test.txt edx-lint==5.3.4 # via -r requirements/quality.in @@ -93,6 +95,8 @@ edx-opaque-keys==2.5.1 # via # -r requirements/test.txt # edx-drf-extensions +edx-rest-api-client==5.6.1 + # via -r requirements/test.txt exceptiongroup==1.1.3 # via # -r requirements/test.txt @@ -139,11 +143,11 @@ pluggy==1.3.0 # via # -r requirements/test.txt # pytest -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/test.txt # edx-django-utils -pycodestyle==2.11.0 +pycodestyle==2.11.1 # via -r requirements/quality.in pycparser==2.21 # via @@ -156,6 +160,8 @@ pyjwt[crypto]==2.8.0 # -r requirements/test.txt # drf-jwt # edx-drf-extensions + # edx-rest-api-client + # pyjwt pylint==2.17.7 # via # edx-lint @@ -205,7 +211,9 @@ requests==2.31.0 # via # -r requirements/test.txt # edx-drf-extensions + # edx-rest-api-client # responses + # slumber responses==0.23.3 # via -r requirements/test.txt semantic-version==2.10.0 @@ -214,6 +222,10 @@ semantic-version==2.10.0 # edx-drf-extensions six==1.16.0 # via edx-lint +slumber==0.7.1 + # via + # -r requirements/test.txt + # edx-rest-api-client snowballstemmer==2.2.0 # via pydocstyle sqlparse==0.4.4 @@ -249,7 +261,7 @@ typing-extensions==4.8.0 # astroid # edx-opaque-keys # pylint -urllib3==2.0.6 +urllib3==2.0.7 # via # -r requirements/test.txt # requests diff --git a/requirements/test.txt b/requirements/test.txt index 7384cd6..fa4b4aa 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -29,7 +29,9 @@ click==8.1.7 code-annotations==1.5.0 # via -r requirements/test.in coverage[toml]==7.3.2 - # via pytest-cov + # via + # coverage + # pytest-cov cryptography==41.0.4 # via # -r requirements/base.txt @@ -70,12 +72,15 @@ edx-django-utils==5.7.0 # via # -r requirements/base.txt # edx-drf-extensions -edx-drf-extensions==8.10.0 + # edx-rest-api-client +edx-drf-extensions==8.12.0 # via -r requirements/base.txt edx-opaque-keys==2.5.1 # via # -r requirements/base.txt # edx-drf-extensions +edx-rest-api-client==5.6.1 + # via -r requirements/base.txt exceptiongroup==1.1.3 # via pytest idna==3.4 @@ -100,7 +105,7 @@ pbr==5.11.1 # stevedore pluggy==1.3.0 # via pytest -psutil==5.9.5 +psutil==5.9.6 # via # -r requirements/base.txt # edx-django-utils @@ -113,6 +118,8 @@ pyjwt[crypto]==2.8.0 # -r requirements/base.txt # drf-jwt # edx-drf-extensions + # edx-rest-api-client + # pyjwt pymongo==3.13.0 # via # -r requirements/base.txt @@ -144,13 +151,19 @@ requests==2.31.0 # via # -r requirements/base.txt # edx-drf-extensions + # edx-rest-api-client # responses + # slumber responses==0.23.3 # via -r requirements/test.in semantic-version==2.10.0 # via # -r requirements/base.txt # edx-drf-extensions +slumber==0.7.1 + # via + # -r requirements/base.txt + # edx-rest-api-client sqlparse==0.4.4 # via # -r requirements/base.txt @@ -174,7 +187,7 @@ typing-extensions==4.8.0 # -r requirements/base.txt # asgiref # edx-opaque-keys -urllib3==2.0.6 +urllib3==2.0.7 # via # -r requirements/base.txt # requests diff --git a/test_settings.py b/test_settings.py index d3d5955..ea6e856 100644 --- a/test_settings.py +++ b/test_settings.py @@ -64,3 +64,8 @@ def root(*args): CHAT_COMPLETION_API_KEY = 'endpoint_key' CHAT_COMPLETION_API_CONNECT_TIMEOUT = 0.5 CHAT_COMPLETION_API_READ_TIMEOUT = 10 + +DISCOVERY_BASE_URL = 'http://edx.devstack.discovery:18381' +DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = 'http://edx.devstack.lms:18000/oauth2' +DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_KEY = 'discovery-backend-service-key' +DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_SECRET = 'discovery-backend-service-secret'