diff --git a/.coveragerc b/.coveragerc
index fdefc7e..c26fb19 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,6 +1,7 @@
[run]
branch = True
data_file = .coverage
+relative_files = True
source=learning_assistant
omit =
test_settings
@@ -8,3 +9,4 @@ omit =
*admin.py
*static*
*templates*
+ learning_assistant/plugins.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e5af682..3ce2313 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,8 +15,8 @@ jobs:
strategy:
matrix:
os: [ubuntu-20.04]
- python-version: ['3.8']
- toxenv: [quality, pii_check, django32, django40]
+ python-version: ['3.12']
+ toxenv: [quality, pii_check, django42]
steps:
- uses: actions/checkout@v2
@@ -37,8 +37,11 @@ jobs:
run: tox
- name: Run coverage
- if: matrix.python-version == '3.8' && matrix.toxenv == 'django32'
- uses: codecov/codecov-action@v1
+ if: matrix.python-version == '3.12' && matrix.toxenv == 'django42'
+ uses: py-cov-action/python-coverage-comment-action@v3
with:
- flags: unittests
- fail_ci_if_error: true
+ GITHUB_TOKEN: ${{ github.token }}
+ MINIMUM_GREEN: 95
+ MINIMUM_ORANGE: 84
+ ANNOTATE_MISSING_LINES: true
+ ANNOTATION_TYPE: error
diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml
index a22e9e6..d7898fd 100644
--- a/.github/workflows/pypi-publish.yml
+++ b/.github/workflows/pypi-publish.yml
@@ -15,7 +15,7 @@ jobs:
- name: setup python
uses: actions/setup-python@v2
with:
- python-version: 3.8
+ python-version: 3.12
- name: Install pip
run: pip install -r requirements/pip.txt
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4aaa3fa..96feb25 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -11,6 +11,132 @@ Change Log
.. There should always be an "Unreleased" section for changes pending release.
+Unreleased
+**********
+
+4.5.0 - 2024-12-04
+******************
+* Add local setup to readme
+* Add a BFFE chat summary endpoint for Learning Assistant, including information about whether the Learning Assistant is
+ enabled, Learning Assistant message history, and Learning Assistant audit trial data.
+
+4.4.7 - 2024-11-25
+******************
+* Fixes the Course Chat View CourseMode concatenation issue
+
+4.4.6 - 2024-11-22
+******************
+* Gates the chat history endpoint behind a waffle flag
+* Add LearningAssistantAuditTrial model
+
+4.4.5 - 2024-11-12
+******************
+* Updated Learning Assistant History payload to return in ascending order
+
+4.4.4 - 2024-11-06
+******************
+* Fixed Learning Assistant History endpoint
+* Added timestamp to the Learning Assistant History payload
+
+4.4.3 - 2024-11-06
+******************
+* Fixed package version
+
+4.4.2 - 2024-11-04
+******************
+* Added chat messages to the DB
+
+4.4.1 - 2024-10-31
+******************
+* Add management command to remove expired messages
+
+4.4.0 - 2024-10-30
+******************
+* Add LearningAssistantMessage model
+* Add new GET endpoint to retrieve a user's message history in a given course.
+
+4.4.0 - 2024-10-25
+******************
+* Upgraded to use ``Python 3.12``
+
+4.3.3 - 2024-10-15
+******************
+* Use `LEARNING_ASSISTANT_PROMPT_TEMPLATE` for prompt
+
+4.3.2 - 2024-09-19
+******************
+* Add error handling for invalid unit usage keys
+
+4.3.1 - 2024-09-10
+******************
+* Remove GPT model field as part of POST request to Xpert backend
+
+4.3.0 - 2024-07-01
+******************
+* Adds optional parameter to use updated prompt and model for the chat response.
+
+4.2.0 - 2024-02-28
+******************
+* Modify call to Xpert backend to prevent use of course index.
+
+4.1.0 - 2024-02-26
+******************
+* Use course cache to inject course title and course skill names into prompt template.
+
+4.0.0 - 2024-02-21
+******************
+* Remove use of course waffle flag. Use the django setting LEARNING_ASSISTANT_AVAILABLE
+ to enable the learning assistant feature.
+
+3.6.0 - 2024-02-13
+******************
+* Enable backend access by course waffle flag or django setting.
+
+3.4.0 - 2024-01-30
+******************
+* Add new GET endpoint to retrieve whether Learning Assistant is enabled in a given course.
+
+3.3.0 - 2024-01-30
+******************
+* Fix release version
+
+3.2.0 - 2024-01-30
+******************
+* Remove audit access to chat view.
+
+3.0.1 - 2024-01-29
+******************
+* Modify gating of learning assistant based on waffle flag and enabled value.
+
+3.0.0 - 2024-01-23
+******************
+* Remove and drop the course prompt model.
+
+2.0.3 - 2024-01-22
+******************
+* Remove references to the course prompt model.
+
+2.0.1 - 2024-01-08
+******************
+* Gate content integration with waffle flag
+
+2.0.0 - 2024-01-03
+******************
+* Add content cache
+* Integrate system prompt setting
+
+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
+
+1.3.3 - 2023-09-07
+******************
+* Allow any enrolled learner to access API.
+
1.3.2 - 2023-08-25
******************
* Remove deserialization of prompt field, as it is represented in the python
diff --git a/README.rst b/README.rst
index 69643e4..aafc186 100644
--- a/README.rst
+++ b/README.rst
@@ -26,7 +26,7 @@ One Time Setup
--------------
.. code-block::
- # Clone the repository
+ # Clone the repository (in the ../src relative to devstack repo)
git clone git@github.com:openedx/learning-assistant.git
cd learning-assistant
@@ -34,6 +34,29 @@ One Time Setup
# Here's how you might do that if you have virtualenvwrapper setup.
mkvirtualenv -p python3.8 learning-assistant
+In your ``requirements/edx/private.txt`` requirements file in edx-platform, add:
+
+.. code-block::
+
+ -e /edx/src/learning-assistant
+
+In your ``lms/envs/private.py`` settings file in edx-platform (create file if necessary), add the below settings. The value of the API key shouldn't matter, because it's not being used at this point, but the setting needs to be there.
+
+.. code-block::
+
+ CHAT_COMPLETION_API = '' # copy url from edx-internal
+ CHAT_COMPLETION_API_KEY = '' # add value though value itself does not matter
+
+ LEARNING_ASSISTANT_PROMPT_TEMPLATE = '' # copy value from edx-internal
+
+ LEARNING_ASSISTANT_AVAILABLE = True
+
+In devstack, run ``make lms-shell`` and run the following command: ``paver install_prereqs;exit``. This will install anything included in your ``private.txt`` requirements file.
+
+In django admin, add the following waffle flag ``learning_assistant.enable_course_content`` and make sure it is turned on for Everyone. The flag should be checked on for: Superusers, Staff, and Authenticated.
+
+This plugin depends on the lms and discovery - both should be running.
+
Every time you develop something in this repo
---------------------------------------------
.. code-block::
diff --git a/codecov.yml b/codecov.yml
deleted file mode 100644
index 4da4768..0000000
--- a/codecov.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-coverage:
- status:
- project:
- default:
- enabled: yes
- target: auto
- patch:
- default:
- enabled: yes
- target: 100%
-
-comment: false
diff --git a/docs/decisions/0002-system-prompt-design-changes.rst b/docs/decisions/0002-system-prompt-design-changes.rst
new file mode 100644
index 0000000..0f59c55
--- /dev/null
+++ b/docs/decisions/0002-system-prompt-design-changes.rst
@@ -0,0 +1,96 @@
+2. System Prompt Design Changes
+###############################
+
+Status
+******
+
+**Accepted** *2023-12-13*
+
+Context
+*******
+
+A system prompt in the Learning Assistant context refers to the text-based instructions provided to the large language
+model (LLM) that describe the objective and proper behavior of the Learning Assistant. This prompt is provided to the
+LLM via 2U's Xpert Platform generic chat completion endpoint as the first set of elements in the ``message_list``
+payload key.
+
+Currently, the system prompt is stored in the `CoursePrompt model`_ on a per-course basis. For each course in which the
+Learning Assistant is enabled, a system prompt must be stored in the associated database table.
+
+The original intention behind storing the system prompt in this way was to enable an expedited release, a greater degree
+of per-course customization, and a flexibility to modify the system prompt quickly in the early stages of the project.
+
+The process of releasing the Learning Assistant to a new course involves either the manual creation of the model
+instance via the Django admin or the use of the `set_course_prompts management command`_. The latter requires that a
+member of 2U's Site Reliability Engineering (SRE) team runs this command in the proper environment.
+
+The next iteration of the Learning Assistant will enable the integration of unit content into the system prompt to
+provide the LLM with more information about the context in which the learner is asking a question. This will require a
+change to the system prompt to accommodate the unit content, and will, thus, require manual work on the part of an
+engineer to update the existing system prompts and to create new system prompts as we approach a full roll out. This
+presents an opportunity to reconsider the way that we store and process the system prompt.
+
+Decision
+********
+
+* We will store a system prompt template in a Django setting in the form of a Jinja template.
+* We will use Jinja constructs, such as variables and control structures, to implement a single system prompt template
+ for all courses.
+* The system prompt template will be rendered in two steps.
+
+ * The first render step will be performed by the `learning-assistant`_ code. This step will interpolate any variables
+ for which this code has a value (e.g. unit content).
+ * The second render step will be performed by the 2U Xpert Platform generic chat completion endpoint. This step will
+ interpolate any variable for which the platform has a value (e.g. course title and skill names).
+
+* After the first render step, the resulting value will be a Jinja template that has been partially rendered. This
+ template will be sent to 2U's Xpert Platform generic chat completion endpoint to be completely rendered.
+* We will remove the `CoursePrompt model`_ following the instructions documented in
+ `Everything About Database Migrations`_.
+
+Consequences
+************
+
+* Changes to the system prompt will require pull requests to the appropriate repository in which the prompt is stored.
+* Anyone with access to the appropriate repository in which the prompt is stored can change the system prompt. Members
+ of the team will no longer require assistance from the SRE team.
+* The transition to a system prompt template required the 2U Xpert Platform team to provide support for accepting and
+ rendering a system prompt template and the integration of Discovery ``skill_names`` into their index.
+* The use of a system prompt template will require cross-team collaboration to ensure that the same variable names are
+ use in the system prompt template, in the `learning-assistant`_ code, and the 2U Xpert Platform generic chat
+ completion endpoint.
+* Because the system prompt template will be stored in a Django setting in a private repository, and the code that
+ renders the template is stored here, changes to the template will require careful coordination to ensure that the
+ template is rendered properly.
+
+ * For example, if a new variable is added to the template, the code must be modified and deployed in advanced of
+ changes to the template. Otherwise, if changes to template are deployed before the related code changes, then the
+ rendered template will contain uninterpolated variables.
+
+* It will become more difficult to enable per-course customizations because all courses will be served by a single
+ system prompt template.
+
+Rejected Alternatives
+*********************
+
+* Status Quo
+
+ * The main alternative to this change is to continue to use manual entry and the `set_course_prompts management command`_
+ to manage system prompts.
+ * To enable unit content integration, we would need to modify the JSON string stored in the `CoursePrompt model`_ to
+ store a JSON string with format string variable for the content, which would be interpolated at runtime.
+ * The main advantage of this alternative is that it will require less engineering work.
+ * The main disadvantage of this alternative is that it will become challenging to manage which prompt a course should
+ use. For example, to do a stage released of unit content integration, we would be required to manage different
+ templates manually. Later, a full release would require additional changes. This would make management of the
+ templates even more tedious.
+ * Managing system prompts in a full release would become intractable.
+ * Additionally, in order to better operationalize the use of the management command and to reduce our reliance on the
+ SRE team, we would likely want to invest time in setting up a Jenkins job to run the management command ad-hoc. If
+ we will be investing engineering resources in this area anyway, we felt it was a more future-proof approach to
+ pursue the solution described above.
+
+.. _set_course_prompts management command: https://github.com/edx/learning-assistant/blob/main/learning_assistant/management/commands/set_course_prompts.py
+.. _CoursePrompt model: https://github.com/edx/learning-assistant/blob/34604a0775f7bd79adb465e0ca51c7759197bfa9/learning_assistant/models.py
+.. _Everything About Database Migrations: https://openedx.atlassian.net/wiki/spaces/AC/pages/23003228/Everything+About+Database+Migrations#EverythingAboutDatabaseMigrations-Howtodropatable
+.. _learning-assistant: https://github.com/edx/learning-assistant
\ No newline at end of file
diff --git a/docs/decisions/0003-courseapp-use.rst b/docs/decisions/0003-courseapp-use.rst
new file mode 100644
index 0000000..eedf67e
--- /dev/null
+++ b/docs/decisions/0003-courseapp-use.rst
@@ -0,0 +1,62 @@
+3. CourseApp Use
+################
+
+Status
+******
+
+**Accepted** *2024-01-08*
+
+Context
+*******
+
+We are adding the ability for course teams to manually enable and disable the Learning Assistant in their courses via
+a card in the `Pages & Resources page`_ of Studio, provided that the Learning Assistant is enabled and available in
+a given course.
+
+The `Pages & Resources page`_ is powered on the backend by the edX Platform `CourseApp Django app`_. With one
+exception in the case of Xpert Unit Summaries, each card on the `Pages & Resources page`_ has a corresponding instance
+of the `CourseApp plugin`_ configured in the `edx-platform repository`_. The `Pages & Resources page`_ can then make
+calls to the `backend CourseApp REST API`_ to get necessary information about the feature described by the plugin.
+
+The one exception to this behavior is the ``Xpert unit summaries card``, which uses a `Javascript object`_ stored in a
+static file in the `frontend-app-course-authoring repository`_ to describe the options that would otherwise be returned
+by the `backend CourseApp REST API`_ using a corresponding ``CourseApp`` plugin.
+
+Currently, the only ``CourseApp`` plugins registered in the platfrom are those that are defined within the
+`edx-platform repository`_. There are currently no plugins that are defined outside of the `edx-platform repository`_.
+
+Decision
+********
+
+* We will define an instance of the `CourseApp plugin`_ in this repository to describe the Learning Assistant feature.
+* We will register the `CourseApp plugin`_ as an entrypoint with the ``openedx.course_app`` key so that the `CourseApp
+ Django app`_ will be able to pick up the plugin if the Learning Assistant plugin is installed into ``edx-platform``.
+
+Consequences
+************
+
+* The Learning Assistant ``CourseApp`` plugin will only be registered with the `CourseApp Django app`_ if, and,
+ therefore, the Learning Assistant card on the `Pages & Resources page`_ will only be visible if, the Learning
+ Assistant plugin is installed into ``edx-platform``.
+* It will become slightly more difficult to know which ``CourseApps`` are available simply by reading the code, because
+ this ``CourseApp`` plugin is not stored in the `edx-platform repository`_.
+* Because the `CourseApps` API is registered under the CMS application, the Learning Assistant needs to be registered as
+ a plugin to the CMS as well. Otherwise, the plugin is not included in the CMS application's ``INSTALLED_APPS`` list.
+ This causes a runtime error, because the Learning Assistant CourseApp plugin will refer to the Learning Assistant's
+ models, and this are not available in the CMS if the Learning Assistant plugin is not installed.
+
+Rejected Alternatives
+*********************
+
+* We decided not to add in a custom backend REST API for exposing the ability to introspect the Learning Assistant
+ feature and enable and disable it. There already exists the `CourseApp Django app`_ for this purpose, and using it
+ allows us to avoid writing a lot of ad hoc code.
+
+.. _backend CourseApp REST API: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/course_apps/rest_api/v1/views.py#L80
+.. _CourseApp Django app: https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/course_apps
+.. _CourseApp plugin: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/course_apps/plugins.py#L15
+.. _edx-platform: https://github.com/openedx/edx-platform
+.. _edx-platform repository: https://github.com/openedx/edx-platform
+.. _frontend-app-course-authoring repository: https://github.com/openedx/frontend-app-course-authoring/tree/master
+.. _Javascript object: https://github.com/openedx/frontend-app-course-authoring/blob/master/src/pages-and-resources/xpert-unit-summary/appInfo.js
+.. _Pages & Resources page: https://github.com/openedx/frontend-app-course-authoring/tree/master/src/pages-and-resources
diff --git a/docs/decisions/0004-course-cache-use.rst b/docs/decisions/0004-course-cache-use.rst
new file mode 100644
index 0000000..13db245
--- /dev/null
+++ b/docs/decisions/0004-course-cache-use.rst
@@ -0,0 +1,50 @@
+4. Use of the LMS course caches
+###############################
+
+Status
+******
+
+**Accepted** *2024-02-26*
+
+Context
+*******
+Each course run ID in edx-platform is associated with another course ID. While for many courses, the mapping between
+course run ID and course ID is straight forward, i.e. edX+testX+2019 -> edX+testX, this is not the case for every
+course on edX. The discovery service is the source of truth for mappings between course run ID and course IDs, and
+string manipulation cannot be relied on as an accurate way to map between the two forms of ID.
+
+The learning-assistant `CourseChatView`_ accepts a course run ID as a path parameter, but a number of API functions
+in the learning assistant backend also require the course ID associated with a given course.
+
+In our initial release, we also found that the current courses available in 2U's Xpert Platform team's index, which was
+being used to inject course skill names and course titles into the system prompt (see `System Prompt Design Changes`_ for
+original details), were too limited. Courses included in that index were conditionally added depending on course
+enrollment dates and additional fields from the discovery course API. While the 2U Xpert Platform team may work to address
+the gap in product needs for their current course index, an alternate method for retrieving course skills and title should
+be considered.
+
+Decision
+********
+In order to determine the mapping between a course run ID and course ID in the learning-assistant app, we will make
+use of an `existing course run cache that is defined in edx-platform`_. Similarly, to retrieve the skill names and title of a course, we will also use
+an `existing course cache`_. Both caches store data from the discovery API for course runs and courses, respectively.
+These are long term caches with a TTL of 24 hours, and on a cache miss the discovery API will be called.
+
+Consequences
+************
+* If the caches were to be removed, code in the learning-assistant repository would no longer function as expected.
+* On a cache miss, the learning-assistant backend will incur additional performance cost on calls to the discovery API.
+
+Rejected Alternatives
+*********************
+* Calling the discovery API directly from the learning-assistant backend
+ * This would require building a custom solution in the learning-assistant app to call the discovery service directly.
+ * Without a cache, this would impact performance on every call to the learning-assistant backend.
+* Using string manipulation to map course run ID to course ID.
+ * If we do not use the discovery service as our source of truth for course run ID to course ID mappings,
+ we run the risk of being unable to support courses that do not fit the usual pattern mapping.
+
+.. _existing course run cache that is defined in edx-platform: https://github.com/openedx/edx-platform/blob/c61df904c1d2a5f523f1da44460c21e17ec087ee/openedx/core/djangoapps/catalog/utils.py#L801
+.. _CourseChatView: https://github.com/edx/learning-assistant/blob/fddf0bc27016bd4a1cabf82de7bcb80b51f3763b/learning_assistant/views.py#L29
+.. _System Prompt Design Changes: https://github.com/edx/learning-assistant/blob/main/docs/decisions/0002-system-prompt-design-changes.rst
+.. _existing course cache: https://github.com/openedx/edx-platform/blob/3a2b6dd8fcc909fd9128f81750f52650ba8ff906/openedx/core/djangoapps/catalog/utils.py#L767
diff --git a/docs/modifying-system-prompt-template.rst b/docs/modifying-system-prompt-template.rst
new file mode 100644
index 0000000..f95e43e
--- /dev/null
+++ b/docs/modifying-system-prompt-template.rst
@@ -0,0 +1,48 @@
+Modifying the System Prompt Template
+####################################
+
+Context
+*******
+
+Because the system prompt template will be stored in a Django setting in a private repository, and the code that
+renders the template is stored here, changes to the template will require careful coordination to ensure that the
+template is rendered properly.
+
+For example, if a new variable is added to the template, the code must be modified and deployed in advanced of
+changes to the template. Otherwise, if changes to template are deployed before the related code changes, then the
+rendered template will contain uninterpolated variables.
+
+This document describes how to properly modify the system prompt template and the code that renders it. These steps
+are only required when your change introduces a new dependency between the template and the code. For example, the
+introductin of a new variable introduces a new dependency, because the code must provide the value for this variable
+when the template is rendered for the variable to be interpolated properly. Additionally, renaming a variable or
+removing a variable would also require you to follow these steps. On the other hand, using an existing variable in a new
+way or changing static text in the template would not require you to follow these steps, because these changes would not
+require a related change to the code.
+
+Adding to the Template
+**********************
+
+If you are adding to the template, then you must follow these steps.
+
+#. Modify the code to supply the correct values to the function that renders the template.
+#. Merge and deploy the code changes.
+#. Modify the template.
+#. Merge and deploy the template changes.
+
+Removing From the Template
+**************************
+
+If you are removing from the template, then you must follow these steps.
+
+#. Modify the template.
+#. Merge and deploy the template changes.
+#. Modify the code to supply the correct values to the function that renders the template.
+#. Merge and deploy the code changes.
+
+Adding to and Removing From the Template
+****************************************
+
+Combination changes will require that the changes are divided into additions and removals. Divide your changes into
+additions and removals and follow the above steps for adding to the template and removing from the template,
+respectively.
diff --git a/learning_assistant/__init__.py b/learning_assistant/__init__.py
index 080d4b1..b14e6c9 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.3.2'
+__version__ = '4.5.0'
default_app_config = 'learning_assistant.apps.LearningAssistantConfig' # pylint: disable=invalid-name
diff --git a/learning_assistant/admin.py b/learning_assistant/admin.py
index a513a90..207d98d 100644
--- a/learning_assistant/admin.py
+++ b/learning_assistant/admin.py
@@ -3,13 +3,11 @@
"""
from django.contrib import admin
-from learning_assistant.models import CoursePrompt
+from learning_assistant.models import LearningAssistantCourseEnabled
-@admin.register(CoursePrompt)
-class CoursePromptAdmin(admin.ModelAdmin):
+@admin.register(LearningAssistantCourseEnabled)
+class LearningAssistantCourseEnabledAdmin(admin.ModelAdmin):
"""
- Admin panel for course prompts.
+ Admin panel for the LearningAssistantCourseEnabled model.
"""
-
- list_display = ('id', 'course_id')
diff --git a/learning_assistant/api.py b/learning_assistant/api.py
index fbc9bf3..9483888 100644
--- a/learning_assistant/api.py
+++ b/learning_assistant/api.py
@@ -1,25 +1,334 @@
"""
Library for the learning_assistant app.
"""
-from learning_assistant.models import CoursePrompt
+import datetime
+import logging
+from datetime import datetime, timedelta
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from edx_django_utils.cache import get_cache_key
+from jinja2 import BaseLoader, Environment
+from opaque_keys import InvalidKeyError
-def get_deserialized_prompt_content_by_course_id(course_id):
+try:
+ from common.djangoapps.course_modes.models import CourseMode
+except ImportError:
+ CourseMode = None
+
+from learning_assistant.constants import ACCEPTED_CATEGORY_TYPES, CATEGORY_TYPE_MAP
+from learning_assistant.data import LearningAssistantAuditTrialData, LearningAssistantCourseEnabledData
+from learning_assistant.models import (
+ LearningAssistantAuditTrial,
+ LearningAssistantCourseEnabled,
+ LearningAssistantMessage,
+)
+from learning_assistant.platform_imports import (
+ block_get_children,
+ block_leaf_filter,
+ get_cache_course_data,
+ get_cache_course_run_data,
+ get_single_block,
+ get_text_transcript,
+ traverse_block_pre_order,
+)
+from learning_assistant.text_utils import html_to_text
+
+log = logging.getLogger(__name__)
+User = get_user_model()
+
+
+def _extract_block_contents(child, category):
"""
- Return a deserialized prompt given a course_id.
+ Process the child contents based on its category.
+
+ Returns a string or None if there are no contents available.
"""
- json_prompt = CoursePrompt.get_json_prompt_content_by_course_id(course_id)
- if json_prompt:
- return json_prompt
+ if category == 'html':
+ content_html = child.get_html()
+ text = html_to_text(content_html)
+ return text
+
+ if category == 'video':
+ transcript = get_text_transcript(child) # may be None
+ return transcript
+
return None
-def get_setup_messages(course_id):
+def _leaf_filter(block):
"""
- Return a list of setup messages given a course id.
+ Return only leaf nodes of a particular category.
"""
- message_content = get_deserialized_prompt_content_by_course_id(course_id)
- if message_content:
- setup_messages = [{'role': 'system', 'content': x} for x in message_content]
- return setup_messages
- return None
+ is_leaf = block_leaf_filter(block)
+ category = block.category
+
+ return is_leaf and category in ACCEPTED_CATEGORY_TYPES
+
+
+def _get_children_contents(block):
+ """
+ Given a specific block, return the content type and text of a pre-order traversal of the blocks children.
+ """
+ leaf_nodes = traverse_block_pre_order(block, block_get_children, _leaf_filter)
+
+ length = 0
+ items = []
+
+ for node in leaf_nodes:
+ category = node.category
+ content = _extract_block_contents(node, category)
+
+ if content:
+ length += len(content)
+ items.append({
+ 'content_type': CATEGORY_TYPE_MAP.get(category),
+ 'content_text': content,
+ })
+
+ return length, items
+
+
+def get_block_content(request, user_id, course_id, unit_usage_key):
+ """
+ Public wrapper for retrieving the content of a given block's children.
+
+ Returns
+ length - the cummulative length of a block's children's content
+ items - a list of dictionaries containing the content type and text for each child
+ """
+ cache_key = get_cache_key(
+ resource='learning_assistant',
+ user_id=user_id,
+ course_id=course_id,
+ unit_usage_key=unit_usage_key
+ )
+ cache_data = cache.get(cache_key)
+
+ if not isinstance(cache_data, dict):
+ block = get_single_block(request, user_id, course_id, unit_usage_key)
+ length, items = _get_children_contents(block)
+ cache_data = {'content_length': length, 'content_items': items}
+ cache.set(cache_key, cache_data, getattr(settings, 'LEARNING_ASSISTANT_CACHE_TIMEOUT', 360))
+
+ return cache_data['content_length'], cache_data['content_items']
+
+
+def render_prompt_template(request, user_id, course_run_id, unit_usage_key, course_id, template_string):
+ """
+ Return a rendered prompt template.
+ """
+ unit_content = ''
+
+ if unit_usage_key:
+ try:
+ _, unit_content = get_block_content(request, user_id, course_run_id, unit_usage_key)
+ except InvalidKeyError:
+ log.warning(
+ 'Failed to retrieve course content for course_id=%(course_run_id)s because of '
+ 'invalid unit_id=%(unit_usage_key)s',
+ {'course_run_id': course_run_id, 'unit_usage_key': unit_usage_key}
+ )
+
+ course_data = get_cache_course_data(course_id, ['skill_names', 'title'])
+ skill_names = course_data['skill_names']
+ title = course_data['title']
+
+ template = Environment(loader=BaseLoader).from_string(template_string)
+ data = template.render(unit_content=unit_content, skill_names=skill_names, title=title)
+ return data
+
+
+def learning_assistant_available():
+ """
+ Return whether or not the learning assistant is available via django setting or course waffle flag.
+ """
+ return getattr(settings, 'LEARNING_ASSISTANT_AVAILABLE', False)
+
+
+def learning_assistant_enabled(course_key):
+ """
+ Return whether the Learning Assistant is enabled in the course represented by the course_key.
+
+ The Learning Assistant is enabled if the feature is available (i.e. appropriate CourseWaffleFlag is enabled) and
+ either there is no override in the LearningAssistantCourseEnabled table or there is an enabled value in the
+ LearningAssistantCourseEnabled table.
+
+ Arguments:
+ * course_key: (CourseKey): the course's key
+
+ Returns:
+ * bool: whether the Learning Assistant is enabled
+ """
+ try:
+ obj = LearningAssistantCourseEnabled.objects.get(course_id=course_key)
+ enabled = obj.enabled
+ except LearningAssistantCourseEnabled.DoesNotExist:
+ # Currently, the Learning Assistant defaults to enabled if there is no override.
+ enabled = True
+
+ return learning_assistant_available() and enabled
+
+
+def set_learning_assistant_enabled(course_key, enabled):
+ """
+ Set whether the Learning Assistant is enabled and return a representation of the created data.
+
+ Arguments:
+ * course_key: (CourseKey): the course's key
+ * enabled (bool): whether the Learning Assistant should be enabled
+
+ Returns:
+ * bool: whether the Learning Assistant is enabled
+ """
+ obj, _ = LearningAssistantCourseEnabled.objects.update_or_create(
+ course_id=course_key,
+ defaults={'enabled': enabled}
+ )
+
+ return LearningAssistantCourseEnabledData(
+ course_key=obj.course_id,
+ enabled=obj.enabled
+ )
+
+
+def get_course_id(course_run_id):
+ """
+ Given a course run id (str), return the associated course key.
+ """
+ course_data = get_cache_course_run_data(course_run_id, ['course'])
+ course_key = course_data['course']
+ return course_key
+
+
+def save_chat_message(courserun_key, user_id, chat_role, message):
+ """
+ Save the chat message to the database.
+ """
+ user = None
+ try:
+ user = User.objects.get(id=user_id)
+ except User.DoesNotExist as exc:
+ raise Exception("User does not exists.") from exc
+
+ # Save the user message to the database.
+ LearningAssistantMessage.objects.create(
+ course_id=courserun_key,
+ user=user,
+ role=chat_role,
+ content=message,
+
+ )
+
+
+def get_message_history(courserun_key, user, message_count):
+ """
+ Given a courserun key (CourseKey), user (User), and message count (int), return the associated message history.
+
+ Returns a number of messages equal to the message_count value.
+ """
+ # Explanation over the double reverse: This fetches the last message_count elements ordered by creating order DESC.
+ # Slicing the list in the model is an equivalent of adding LIMIT on the query.
+ # The result is the last chat messages for that user and course but in inversed order, so in order to flip them
+ # its first turn into a list and then reversed.
+ message_history = list(LearningAssistantMessage.objects.filter(
+ course_id=courserun_key, user=user).order_by('-created')[:message_count])[::-1]
+ return message_history
+
+
+def get_audit_trial_expiration_date(start_date):
+ """
+ Given a start date of an audit trial, calculate the expiration date of the audit trial.
+
+ Arguments:
+ * start_date (datetime): the start date of the audit trial
+
+ Returns:
+ * expiration_date (datetime): the expiration date of the audit trial
+ """
+ default_trial_length_days = 14
+
+ trial_length_days = getattr(settings, 'LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS', default_trial_length_days)
+
+ if trial_length_days is None:
+ trial_length_days = default_trial_length_days
+
+ # If LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS is set to a negative number, assume it should be 0.
+ # pylint: disable=consider-using-max-builtin
+ if trial_length_days < 0:
+ trial_length_days = 0
+
+ expiration_datetime = start_date + timedelta(days=trial_length_days)
+ return expiration_datetime
+
+
+def get_audit_trial(user):
+ """
+ Given a user, return the associated audit trial data.
+
+ Arguments:
+ * user (User): the user
+
+ Returns:
+ * audit_trial_data (LearningAssistantAuditTrialData): the audit trial data
+ * user_id (int): the user's id
+ * start_date (datetime): the start date of the audit trial
+ * expiration_date (datetime): the expiration date of the audit trial
+ * None: if no audit trial exists for the user
+ """
+ try:
+ audit_trial = LearningAssistantAuditTrial.objects.get(user=user)
+ except LearningAssistantAuditTrial.DoesNotExist:
+ return None
+
+ return LearningAssistantAuditTrialData(
+ user_id=user.id,
+ start_date=audit_trial.start_date,
+ expiration_date=get_audit_trial_expiration_date(audit_trial.start_date),
+ )
+
+
+def get_or_create_audit_trial(user):
+ """
+ Given a user, return the associated audit trial data, creating a new audit trial for the user if one does not exist.
+
+ Arguments:
+ * user (User): the user
+
+ Returns:
+ * audit_trial_data (LearningAssistantAuditTrialData): the audit trial data
+ * user_id (int): the user's id
+ * start_date (datetime): the start date of the audit trial
+ * expiration_date (datetime): the expiration date of the audit trial
+ """
+ audit_trial, _ = LearningAssistantAuditTrial.objects.get_or_create(
+ user=user,
+ defaults={
+ "start_date": datetime.now(),
+ },
+ )
+
+ return LearningAssistantAuditTrialData(
+ user_id=user.id,
+ start_date=audit_trial.start_date,
+ expiration_date=get_audit_trial_expiration_date(audit_trial.start_date),
+ )
+
+
+def audit_trial_is_expired(audit_trial_data, courserun_key):
+ """
+ Given a user (User), get or create the corresponding LearningAssistantAuditTrial trial object.
+ """
+ course_mode = CourseMode.objects.get(course=courserun_key)
+
+ upgrade_deadline = course_mode.expiration_datetime()
+
+ # If the upgrade deadline has passed, return True for expired. Upgrade deadline is an optional attribute of a
+ # CourseMode, so if it's None, then do not return True.
+ days_until_upgrade_deadline = datetime.now() - upgrade_deadline if upgrade_deadline else None
+ if days_until_upgrade_deadline is not None and days_until_upgrade_deadline >= timedelta(days=0):
+ return True
+
+ # If the user's trial is past its expiry date, return True for expired. Else, return False.
+ return audit_trial_data is None or audit_trial_data.expiration_date <= datetime.now()
diff --git a/learning_assistant/constants.py b/learning_assistant/constants.py
index 580616c..92e3717 100644
--- a/learning_assistant/constants.py
+++ b/learning_assistant/constants.py
@@ -7,4 +7,12 @@
EXTERNAL_COURSE_KEY_PATTERN = r'([A-Za-z0-9-_:]+)'
-COURSE_ID_PATTERN = rf'(?P({INTERNAL_COURSE_KEY_PATTERN}|{EXTERNAL_COURSE_KEY_PATTERN}))'
+COURSE_ID_PATTERN = rf'(?P({INTERNAL_COURSE_KEY_PATTERN}|{EXTERNAL_COURSE_KEY_PATTERN}))'
+
+ACCEPTED_CATEGORY_TYPES = ['html', 'video']
+CATEGORY_TYPE_MAP = {
+ "html": "TEXT",
+ "video": "VIDEO",
+}
+
+AUDIT_TRIAL_MAX_DAYS = 14
diff --git a/learning_assistant/data.py b/learning_assistant/data.py
new file mode 100644
index 0000000..e9e923b
--- /dev/null
+++ b/learning_assistant/data.py
@@ -0,0 +1,28 @@
+"""
+Data classes for the Learning Assistant application.
+"""
+from datetime import datetime
+
+from attrs import field, frozen, validators
+from opaque_keys.edx.keys import CourseKey
+
+
+@frozen
+class LearningAssistantCourseEnabledData:
+ """
+ Data class representing whether Learning Assistant is enabled in a course.
+ """
+
+ course_key: CourseKey = field(validator=validators.instance_of(CourseKey))
+ enabled: bool = field(validator=validators.instance_of(bool))
+
+
+@frozen
+class LearningAssistantAuditTrialData:
+ """
+ Data class representing an audit learner's trial of the Learning Assistant.
+ """
+
+ user_id: int = field(validator=validators.instance_of(int))
+ start_date: datetime = field(validator=validators.optional(validators.instance_of(datetime)))
+ expiration_date: datetime = field(validator=validators.optional(validators.instance_of(datetime)))
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/retire_user_messages.py b/learning_assistant/management/commands/retire_user_messages.py
new file mode 100644
index 0000000..8b90082
--- /dev/null
+++ b/learning_assistant/management/commands/retire_user_messages.py
@@ -0,0 +1,68 @@
+""""
+Django management command to remove LearningAssistantMessage objects
+if they have reached their expiration date.
+"""
+import logging
+import time
+from datetime import datetime, timedelta
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+
+from learning_assistant.models import LearningAssistantMessage
+
+log = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ """
+ Django Management command to remove expired messages.
+ """
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--batch_size',
+ action='store',
+ dest='batch_size',
+ type=int,
+ default=300,
+ help='Maximum number of messages to remove. '
+ 'This helps avoid overloading the database while updating large amount of data.'
+ )
+ parser.add_argument(
+ '--sleep_time',
+ action='store',
+ dest='sleep_time',
+ type=int,
+ default=10,
+ help='Sleep time in seconds between update of batches'
+ )
+
+ def handle(self, *args, **options):
+ """
+ Management command entry point.
+ """
+ batch_size = options['batch_size']
+ sleep_time = options['sleep_time']
+
+ expiry_date = datetime.now() - timedelta(days=getattr(settings, 'LEARNING_ASSISTANT_MESSAGES_EXPIRY', 30))
+
+ total_deleted = 0
+ deleted_count = None
+
+ while deleted_count != 0:
+ ids_to_delete = LearningAssistantMessage.objects.filter(
+ created__lte=expiry_date
+ ).values_list('id', flat=True)[:batch_size]
+
+ ids_to_delete = list(ids_to_delete)
+ delete_queryset = LearningAssistantMessage.objects.filter(
+ id__in=ids_to_delete
+ )
+ deleted_count, _ = delete_queryset.delete()
+
+ total_deleted += deleted_count
+ log.info(f'{deleted_count} messages deleted.')
+ time.sleep(sleep_time)
+
+ log.info(f'Job completed. {total_deleted} messages deleted.')
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_retire_user_messages.py b/learning_assistant/management/commands/tests/test_retire_user_messages.py
new file mode 100644
index 0000000..afd720f
--- /dev/null
+++ b/learning_assistant/management/commands/tests/test_retire_user_messages.py
@@ -0,0 +1,68 @@
+"""
+Tests for the retire_user_messages management command
+"""
+from datetime import datetime, timedelta
+
+from django.contrib.auth import get_user_model
+from django.core.management import call_command
+from django.test import TestCase
+
+from learning_assistant.models import LearningAssistantMessage
+
+User = get_user_model()
+
+
+class RetireUserMessagesTests(TestCase):
+ """
+ Tests for the retire_user_messages command.
+ """
+
+ def setUp(self):
+ """
+ Build up test data
+ """
+ super().setUp()
+ self.user = User(username='tester', email='tester@test.com')
+ self.user.save()
+
+ self.course_id = 'course-v1:edx+test+23'
+
+ LearningAssistantMessage.objects.create(
+ user=self.user,
+ course_id=self.course_id,
+ role='user',
+ content='Hello',
+ created=datetime.now() - timedelta(days=60)
+ )
+
+ LearningAssistantMessage.objects.create(
+ user=self.user,
+ course_id=self.course_id,
+ role='user',
+ content='Hello',
+ created=datetime.now() - timedelta(days=2)
+ )
+
+ LearningAssistantMessage.objects.create(
+ user=self.user,
+ course_id=self.course_id,
+ role='user',
+ content='Hello',
+ created=datetime.now() - timedelta(days=4)
+ )
+
+ def test_run_command(self):
+ """
+ Run the management command
+ """
+ current_messages = LearningAssistantMessage.objects.filter()
+ self.assertEqual(len(current_messages), 3)
+
+ call_command(
+ 'retire_user_messages',
+ batch_size=2,
+ sleep_time=0,
+ )
+
+ current_messages = LearningAssistantMessage.objects.filter()
+ self.assertEqual(len(current_messages), 2)
diff --git a/learning_assistant/migrations/0005_learningassistantcourseenabled.py b/learning_assistant/migrations/0005_learningassistantcourseenabled.py
new file mode 100644
index 0000000..c98d3bf
--- /dev/null
+++ b/learning_assistant/migrations/0005_learningassistantcourseenabled.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.23 on 2024-01-04 15:02
+
+from django.db import migrations, models
+import django.utils.timezone
+import model_utils.fields
+import opaque_keys.edx.django.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('learning_assistant', '0004_remove_courseprompt_prompt'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='LearningAssistantCourseEnabled',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255, unique=True)),
+ ('enabled', models.BooleanField()),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/learning_assistant/migrations/0006_delete_courseprompt.py b/learning_assistant/migrations/0006_delete_courseprompt.py
new file mode 100644
index 0000000..596d506
--- /dev/null
+++ b/learning_assistant/migrations/0006_delete_courseprompt.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.2.23 on 2024-01-12 13:02
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('learning_assistant', '0005_learningassistantcourseenabled'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='CoursePrompt',
+ ),
+ ]
diff --git a/learning_assistant/migrations/0007_learningassistantmessage.py b/learning_assistant/migrations/0007_learningassistantmessage.py
new file mode 100644
index 0000000..88a1606
--- /dev/null
+++ b/learning_assistant/migrations/0007_learningassistantmessage.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.2.15 on 2024-10-22 12:17
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+import opaque_keys.edx.django.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('learning_assistant', '0006_delete_courseprompt'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='LearningAssistantMessage',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)),
+ ('role', models.CharField(max_length=64)),
+ ('content', models.TextField()),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/learning_assistant/migrations/0008_alter_learningassistantmessage_role.py b/learning_assistant/migrations/0008_alter_learningassistantmessage_role.py
new file mode 100644
index 0000000..bd699b3
--- /dev/null
+++ b/learning_assistant/migrations/0008_alter_learningassistantmessage_role.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.14 on 2024-11-04 08:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('learning_assistant', '0007_learningassistantmessage'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='learningassistantmessage',
+ name='role',
+ field=models.CharField(choices=[('user', 'user'), ('assistant', 'assistant')], max_length=64),
+ ),
+ ]
diff --git a/learning_assistant/migrations/0009_learningassistantaudittrial.py b/learning_assistant/migrations/0009_learningassistantaudittrial.py
new file mode 100644
index 0000000..30068a2
--- /dev/null
+++ b/learning_assistant/migrations/0009_learningassistantaudittrial.py
@@ -0,0 +1,31 @@
+# Generated by Django 4.2.16 on 2024-11-14 13:55
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('learning_assistant', '0008_alter_learningassistantmessage_role'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='LearningAssistantAuditTrial',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('start_date', models.DateTimeField()),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, unique=True)),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/learning_assistant/models.py b/learning_assistant/models.py
index f4313f0..bd05ceb 100644
--- a/learning_assistant/models.py
+++ b/learning_assistant/models.py
@@ -1,32 +1,64 @@
"""
Database models for learning_assistant.
"""
+from django.contrib.auth import get_user_model
from django.db import models
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
+USER_MODEL = get_user_model()
-class CoursePrompt(TimeStampedModel):
+
+class LearningAssistantCourseEnabled(TimeStampedModel):
"""
- This model represents a mapping between a particular course ID and a text prompt associated with the course ID.
+ This model stores whether the Learning Assistant is enabled for a particular course ID.
+
+ For now, the purpose of this model is to store overrides added by course team members. By default, the Learning
+ Assistant will be enabled via a CourseWaffleFlag. This model will store whether course team members have manually
+ disabled the Learning Assistant.
.. no_pii: This model has no PII.
"""
- # course ID with which the text prompt is associated
+ # course ID with for the course in which the Learning Assistant is enabled or disabled
course_id = CourseKeyField(max_length=255, db_index=True, unique=True)
- # a json representation of the prompt message content
- json_prompt_content = models.JSONField(null=True)
-
- @classmethod
- def get_json_prompt_content_by_course_id(cls, course_id):
- """
- Return a json representation of a prompt for a given course id.
- """
- try:
- prompt_object = cls.objects.get(course_id=course_id)
- json_prompt_content = prompt_object.json_prompt_content
- except cls.DoesNotExist:
- json_prompt_content = None
- return json_prompt_content
+ enabled = models.BooleanField()
+
+
+class LearningAssistantMessage(TimeStampedModel):
+ """
+ This model stores messages sent to and received from the learning assistant.
+
+ .. pii: content
+ .. pii_types: other
+ .. pii_retirement: third_party
+ """
+
+ USER_ROLE = 'user'
+ ASSISTANT_ROLE = 'assistant'
+
+ Roles = (
+ (USER_ROLE, USER_ROLE),
+ (ASSISTANT_ROLE, ASSISTANT_ROLE),
+ )
+
+ course_id = CourseKeyField(max_length=255, db_index=True)
+ user = models.ForeignKey(USER_MODEL, db_index=True, on_delete=models.CASCADE)
+ role = models.CharField(choices=Roles, max_length=64)
+ content = models.TextField()
+
+
+class LearningAssistantAuditTrial(TimeStampedModel):
+ """
+ This model stores the trial period for an audit learner using the learning assistant.
+
+ A LearningAssistantAuditTrial instance will be created on a per user basis,
+ when an audit learner first sends a message using Xpert LA.
+
+ .. no_pii: This model has no PII.
+ """
+
+ # Unique constraint since each user should only have one trial
+ user = models.ForeignKey(USER_MODEL, db_index=True, on_delete=models.CASCADE, unique=True)
+ start_date = models.DateTimeField()
diff --git a/learning_assistant/platform_imports.py b/learning_assistant/platform_imports.py
new file mode 100644
index 0000000..55f13d7
--- /dev/null
+++ b/learning_assistant/platform_imports.py
@@ -0,0 +1,87 @@
+"""
+Contain all imported functions coming out of the platform.
+
+We know these functions will be available at run time, but they
+cannot be imported normally.
+"""
+
+
+def get_text_transcript(video_block):
+ """Get the transcript for a video block in text format, or None."""
+ # pylint: disable=import-outside-toplevel
+ from xmodule.exceptions import NotFoundError
+ from xmodule.video_block.transcripts_utils import get_transcript
+ try:
+ transcript, _, _ = get_transcript(video_block, output_format='txt')
+ except NotFoundError:
+ # some old videos have no transcripts, just accept that reality
+ return None
+ return transcript
+
+
+def get_single_block(request, user_id, course_id, usage_key_string, course=None):
+ """Load a single xblock."""
+ # pylint: disable=import-outside-toplevel
+ from lms.djangoapps.courseware.block_render import load_single_xblock
+ return load_single_xblock(request, user_id, course_id, usage_key_string, course)
+
+
+def traverse_block_pre_order(start_node, get_children, filter_func=None):
+ """Traverse a DAG or tree in pre-order."""
+ # pylint: disable=import-outside-toplevel
+ from openedx.core.lib.graph_traversals import traverse_pre_order
+ return traverse_pre_order(start_node, get_children, filter_func)
+
+
+def block_leaf_filter(block):
+ """Return only leaf nodes."""
+ # pylint: disable=import-outside-toplevel
+ from openedx.core.lib.graph_traversals import leaf_filter
+ return leaf_filter(block)
+
+
+def block_get_children(block):
+ """Return children of a given block."""
+ # pylint: disable=import-outside-toplevel
+ from openedx.core.lib.graph_traversals import get_children
+ return get_children(block)
+
+
+def get_cache_course_run_data(course_run_id, fields):
+ """
+ Return course run related data given a course run id.
+
+ This function makes use of the course run cache in the LMS, which caches data from the discovery service. This is
+ necessary because only the discovery service stores the relation between courseruns and courses.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openedx.core.djangoapps.catalog.utils import get_course_run_data
+ return get_course_run_data(course_run_id, fields)
+
+
+def get_cache_course_data(course_id, fields):
+ """
+ Return course related data given a course id.
+
+ This function makes use of the course cache in the LMS, which caches data from the discovery service. This is
+ necessary because only the discovery service stores course skills data.
+ """
+ # pylint: disable=import-outside-toplevel
+ from openedx.core.djangoapps.catalog.utils import get_course_data
+ return get_course_data(course_id, fields)
+
+
+def get_user_role(user, course_key):
+ """
+ Return the role of the user on the edX platform.
+
+ Arguments:
+ * user (User): the user who's role to get
+ * course_key (CourseKey): the key of the course in which to get the user's role
+
+ Returns:
+ * str: the user's role
+ """
+ # pylint: disable=import-outside-toplevel
+ from lms.djangoapps.courseware.access import get_user_role as platform_get_user_role
+ return platform_get_user_role(user, course_key)
diff --git a/learning_assistant/plugins.py b/learning_assistant/plugins.py
new file mode 100644
index 0000000..f83a1a3
--- /dev/null
+++ b/learning_assistant/plugins.py
@@ -0,0 +1,89 @@
+"""
+Plugins for the Learning Assistant application.
+"""
+from openedx.core.djangoapps.course_apps.plugins import CourseApp
+
+from learning_assistant import plugins_api
+
+
+class LearningAssistantCourseApp(CourseApp):
+ """
+ A CourseApp plugin representing the Learning Assistant feature.
+
+ Please see the associated ADR for more details.
+ """
+
+ app_id = 'learning_assistant'
+ name = 'Learning Assistant'
+ description = 'Use generative AI to power a Learning Assistant using course content.'
+ documentation_links = {
+ 'learn_more_openai': 'https://openai.com/',
+ 'learn_more_openai_data_privacy': 'https://openai.com/api-data-privacy',
+ }
+
+ @classmethod
+ def is_available(cls, course_key): # pylint: disable=unused-argument
+ """
+ Return a boolean indicating this course app's availability for a given course.
+
+ If an app is not available, it will not show up in the UI at all for that course,
+ and it will not be possible to enable/disable/configure it.
+
+ Args:
+ course_key (CourseKey): Course key for course whose availability is being checked.
+
+ Returns:
+ bool: Availability status of app.
+ """
+ return plugins_api.is_available()
+
+ @classmethod
+ def is_enabled(cls, course_key):
+ """
+ Return if this course app is enabled for the provided course.
+
+ Args:
+ course_key (CourseKey): The course key for the course you
+ want to check the status of.
+
+ Returns:
+ bool: The status of the course app for the specified course.
+ """
+ return plugins_api.is_enabled(course_key)
+
+ @classmethod
+ def set_enabled(cls, course_key, enabled, user):
+ """
+ Update the status of this app for the provided course and return the new status.
+
+ Args:
+ course_key (CourseKey): The course key for the course for which the app should be enabled.
+ enabled (bool): The new status of the app.
+ user (User): The user performing this operation.
+
+ Returns:
+ bool: The new status of the course app.
+ """
+ return plugins_api.set_enabled(course_key, enabled, user)
+
+ @classmethod
+ def get_allowed_operations(cls, course_key, user=None):
+ """
+ Return a dictionary of available operations for this app.
+
+ Not all apps will support being configured, and some may support
+ other operations via the UI. This will list, the minimum whether
+ the app can be enabled/disabled and whether it can be configured.
+
+ Args:
+ course_key (CourseKey): The course key for a course.
+ user (User): The user for which the operation is to be tested.
+
+ Returns:
+ A dictionary that has keys like 'enable', 'configure' etc
+ with values indicating whether those operations are allowed.
+
+ get_allowed_operations: function that returns a dictionary of the form
+ {'enable': , 'configure': }.
+ """
+ return plugins_api.get_allowed_operations(course_key, user)
diff --git a/learning_assistant/plugins_api.py b/learning_assistant/plugins_api.py
new file mode 100644
index 0000000..906ccdd
--- /dev/null
+++ b/learning_assistant/plugins_api.py
@@ -0,0 +1,93 @@
+"""
+Concrete implementations of abstract methods of the CourseApp plugin ABC, for use by the LearningAssistantCourseApp.
+
+Because the LearningAssistantCourseApp plugin inherits from the CourseApp class, which is imported from the
+edx-platform, we cannot test that plugin directly, because pytest will run outside the platform context.
+Instead, the CourseApp abstract methods are implemented here and
+imported into and used by the LearningAssistantCourseApp. This way, these implementations can be tested.
+"""
+
+from learning_assistant.api import (
+ learning_assistant_available,
+ learning_assistant_enabled,
+ set_learning_assistant_enabled,
+)
+from learning_assistant.platform_imports import get_user_role
+from learning_assistant.utils import user_role_is_staff
+
+
+def is_available():
+ """
+ Return a boolean indicating this course app's availability for a given course.
+
+ If an app is not available, it will not show up in the UI at all for that course,
+ and it will not be possible to enable/disable/configure it, unless the platform wide setting
+ LEARNING_ASSISTANT_AVAILABLE is set to True.
+
+ Args:
+ course_key (CourseKey): Course key for course whose availability is being checked.
+
+ Returns:
+ bool: Availability status of app.
+ """
+ return learning_assistant_available()
+
+
+def is_enabled(course_key):
+ """
+ Return if this course app is enabled for the provided course.
+
+ Args:
+ course_key (CourseKey): The course key for the course you
+ want to check the status of.
+
+ Returns:
+ bool: The status of the course app for the specified course.
+ """
+ return learning_assistant_enabled(course_key)
+
+
+# pylint: disable=unused-argument
+def set_enabled(course_key, enabled, user):
+ """
+ Update the status of this app for the provided course and return the new status.
+
+ Args:
+ course_key (CourseKey): The course key for the course for which the app should be enabled.
+ enabled (bool): The new status of the app.
+ user (User): The user performing this operation.
+
+ Returns:
+ bool: The new status of the course app.
+ """
+ obj = set_learning_assistant_enabled(course_key, enabled)
+
+ return obj.enabled
+
+
+def get_allowed_operations(course_key, user=None):
+ """
+ Return a dictionary of available operations for this app.
+
+ Not all apps will support being configured, and some may support
+ other operations via the UI. This will list, the minimum whether
+ the app can be enabled/disabled and whether it can be configured.
+
+ Args:
+ course_key (CourseKey): The course key for a course.
+ user (User): The user for which the operation is to be tested.
+
+ Returns:
+ A dictionary that has keys like 'enable', 'configure' etc
+ with values indicating whether those operations are allowed.
+
+ get_allowed_operations: function that returns a dictionary of the form
+ {'enable': , 'configure': }.
+ """
+ if not user:
+ return {'configure': False, 'enable': False}
+ else:
+ user_role = get_user_role(user, course_key)
+ is_staff = user_role_is_staff(user_role)
+
+ return {'configure': False, 'enable': is_staff}
diff --git a/learning_assistant/serializers.py b/learning_assistant/serializers.py
index a212654..1896182 100644
--- a/learning_assistant/serializers.py
+++ b/learning_assistant/serializers.py
@@ -3,6 +3,8 @@
"""
from rest_framework import serializers
+from learning_assistant.models import LearningAssistantMessage
+
class MessageSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
@@ -11,12 +13,25 @@ class MessageSerializer(serializers.Serializer): # pylint: disable=abstract-met
role = serializers.CharField(required=True)
content = serializers.CharField(required=True)
+ timestamp = serializers.DateTimeField(required=False, source='created')
+
+ class Meta:
+ """
+ Serializer metadata.
+ """
+
+ model = LearningAssistantMessage
+ fields = (
+ 'role',
+ 'content',
+ 'timestamp',
+ )
def validate_role(self, value):
"""
Validate that role is one of two acceptable values.
"""
- valid_roles = ['user', 'assistant']
+ valid_roles = [LearningAssistantMessage.USER_ROLE, LearningAssistantMessage.ASSISTANT_ROLE]
if value not in valid_roles:
raise serializers.ValidationError('Must be valid role.')
return value
diff --git a/learning_assistant/text_utils.py b/learning_assistant/text_utils.py
new file mode 100644
index 0000000..a1795e9
--- /dev/null
+++ b/learning_assistant/text_utils.py
@@ -0,0 +1,62 @@
+"""
+Text manipulation utils. This has been copied from the ai-aside repository.
+"""
+
+from html.parser import HTMLParser
+from re import sub
+
+from django.conf import settings
+
+
+def cleanup_text(text):
+ """
+ Remove litter from replacing or manipulating text.
+ """
+ stripped = sub(r'[^\S\r\n]+', ' ', text) # Removing extra spaces
+ stripped = sub(r'\n{2,}', '\n', stripped) # Removing extra new lines
+ stripped = sub(r'(\s+)?\n(\s+)?', '\n', stripped) # Removing starting extra spacesbetween new lines
+ stripped = sub(r'(^(\s+)\n?)|(\n(\s+)?$)', '', stripped) # Trim
+
+ return stripped
+
+
+class _HTMLToTextHelper(HTMLParser): # lint-amnesty
+ """
+ Helper function for html_to_text below.
+ """
+
+ _is_content = True
+
+ def __init__(self):
+ HTMLParser.__init__(self)
+ self.reset()
+ self.fed = []
+
+ def handle_starttag(self, tag, _):
+ """On each tag, check whether this is a tag we think is content."""
+ tags_to_filter = getattr(settings, 'LEARNING_ASSISTANT_HTML_TAGS_TO_REMOVE', None)
+ self._is_content = not (tags_to_filter and tag in tags_to_filter)
+
+ def handle_data(self, data):
+ """Handle tag data by appending text we think is content."""
+ if self._is_content:
+ self.fed.append(data)
+
+ def handle_entityref(self, name):
+ """If there is an entity, append the reference to the text."""
+ if self._is_content:
+ self.fed.append('&%s;' % name)
+
+ def get_data(self):
+ """Join together the separate data chunks into one cohesive string."""
+ return ''.join(self.fed)
+
+
+def html_to_text(html):
+ """Strip the html tags off of the text to return plaintext."""
+ htmlstripper = _HTMLToTextHelper()
+ htmlstripper.feed(html)
+ text = htmlstripper.get_data()
+ text = cleanup_text(text)
+
+ return text
diff --git a/learning_assistant/toggles.py b/learning_assistant/toggles.py
new file mode 100644
index 0000000..61d515e
--- /dev/null
+++ b/learning_assistant/toggles.py
@@ -0,0 +1,51 @@
+"""
+Toggles for learning-assistant app.
+"""
+
+WAFFLE_NAMESPACE = 'learning_assistant'
+
+# .. toggle_name: learning_assistant.enable_course_content
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Waffle flag to enable the course content integration with the learning assistant
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2024-01-08
+# .. toggle_target_removal_date: 2024-01-31
+# .. toggle_tickets: COSMO-80
+ENABLE_COURSE_CONTENT = 'enable_course_content'
+
+# .. toggle_name: learning_assistant.enable_chat_history
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Waffle flag to enable the chat history with the learning assistant
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2024-10-30
+# .. toggle_target_removal_date: 2024-12-31
+# .. toggle_tickets: COSMO-436
+ENABLE_CHAT_HISTORY = 'enable_chat_history'
+
+
+def _is_learning_assistant_waffle_flag_enabled(flag_name, course_key):
+ """
+ Import and return Waffle flag for enabling the summary hook.
+ """
+ # pylint: disable=import-outside-toplevel
+ try:
+ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
+ return CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.{flag_name}', __name__).is_enabled(course_key)
+ except ImportError:
+ return False
+
+
+def course_content_enabled(course_key):
+ """
+ Return whether the learning_assistant.enable_course_content WaffleFlag is on.
+ """
+ return _is_learning_assistant_waffle_flag_enabled(ENABLE_COURSE_CONTENT, course_key)
+
+
+def chat_history_enabled(course_key):
+ """
+ Return whether the learning_assistant.enable_chat_history WaffleFlag is on.
+ """
+ return _is_learning_assistant_waffle_flag_enabled(ENABLE_CHAT_HISTORY, course_key)
diff --git a/learning_assistant/urls.py b/learning_assistant/urls.py
index 169d816..31914b8 100644
--- a/learning_assistant/urls.py
+++ b/learning_assistant/urls.py
@@ -4,14 +4,34 @@
from django.urls import re_path
from learning_assistant.constants import COURSE_ID_PATTERN
-from learning_assistant.views import CourseChatView
+from learning_assistant.views import (
+ CourseChatView,
+ LearningAssistantChatSummaryView,
+ LearningAssistantEnabledView,
+ LearningAssistantMessageHistoryView,
+)
app_name = 'learning_assistant'
urlpatterns = [
re_path(
- fr'learning_assistant/v1/course_id/{COURSE_ID_PATTERN}',
+ fr'learning_assistant/v1/course_id/{COURSE_ID_PATTERN}$',
CourseChatView.as_view(),
name='chat'
),
+ re_path(
+ fr'learning_assistant/v1/course_id/{COURSE_ID_PATTERN}/enabled',
+ LearningAssistantEnabledView.as_view(),
+ name='enabled',
+ ),
+ re_path(
+ fr'learning_assistant/v1/course_id/{COURSE_ID_PATTERN}/history',
+ LearningAssistantMessageHistoryView.as_view(),
+ name='message-history',
+ ),
+ re_path(
+ fr'learning_assistant/v1/course_id/{COURSE_ID_PATTERN}/chat-summary',
+ LearningAssistantChatSummaryView.as_view(),
+ name='chat-summary',
+ ),
]
diff --git a/learning_assistant/utils.py b/learning_assistant/utils.py
index 5920cd0..79a9884 100644
--- a/learning_assistant/utils.py
+++ b/learning_assistant/utils.py
@@ -1,6 +1,7 @@
"""
Utils file for learning-assistant.
"""
+import copy
import json
import logging
@@ -12,7 +13,57 @@
log = logging.getLogger(__name__)
-def get_chat_response(message_list):
+def _estimated_message_tokens(message):
+ """
+ Estimates how many tokens are in a given message.
+ """
+ chars_per_token = 3.5
+ json_padding = 8
+
+ return int((len(message) - message.count(' ')) / chars_per_token) + json_padding
+
+
+def get_reduced_message_list(prompt_template, message_list):
+ """
+ If messages are larger than allotted token amount, return a smaller list of messages.
+ """
+ total_system_tokens = _estimated_message_tokens(prompt_template)
+
+ max_tokens = getattr(settings, 'CHAT_COMPLETION_MAX_TOKENS', 16385)
+ response_tokens = getattr(settings, 'CHAT_COMPLETION_RESPONSE_TOKENS', 1000)
+ remaining_tokens = max_tokens - response_tokens - total_system_tokens
+
+ new_message_list = []
+ # use copy of list, as it is modified as part of the reduction
+ message_list_copy = copy.deepcopy(message_list)
+ total_message_tokens = 0
+
+ while total_message_tokens < remaining_tokens and len(message_list_copy) != 0:
+ new_message = message_list_copy.pop()
+ total_message_tokens += _estimated_message_tokens(new_message['content'])
+ if total_message_tokens >= remaining_tokens:
+ break
+
+ # insert message at beginning of list, because we are traversing the message list from most recent to oldest
+ new_message_list.insert(0, new_message)
+
+ system_message = {'role': 'system', 'content': prompt_template}
+
+ return [system_message] + new_message_list
+
+
+def create_request_body(prompt_template, message_list):
+ """
+ Form request body to be passed to the chat endpoint.
+ """
+ response_body = {
+ 'message_list': get_reduced_message_list(prompt_template, message_list),
+ }
+
+ return response_body
+
+
+def get_chat_response(prompt_template, message_list):
"""
Pass message list to chat endpoint, as defined by the CHAT_COMPLETION_API setting.
"""
@@ -22,7 +73,8 @@ def get_chat_response(message_list):
headers = {'Content-Type': 'application/json', 'x-api-key': completion_endpoint_key}
connect_timeout = getattr(settings, 'CHAT_COMPLETION_API_CONNECT_TIMEOUT', 1)
read_timeout = getattr(settings, 'CHAT_COMPLETION_API_READ_TIMEOUT', 15)
- body = {'message_list': message_list}
+
+ body = create_request_body(prompt_template, message_list)
try:
response = requests.post(
@@ -47,3 +99,16 @@ def get_chat_response(message_list):
chat = 'Completion endpoint is not defined.'
return response_status, chat
+
+
+def user_role_is_staff(role):
+ """
+ Return whether the user role parameter represents that of a staff member.
+
+ Arguments:
+ * role (str): the user's role
+
+ Returns:
+ * bool: whether the user's role is that of a staff member
+ """
+ return role in ('staff', 'instructor')
diff --git a/learning_assistant/views.py b/learning_assistant/views.py
index bff3dff..58351e1 100644
--- a/learning_assistant/views.py
+++ b/learning_assistant/views.py
@@ -3,7 +3,9 @@
"""
import logging
+from django.conf import settings
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
+from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import status as http_status
from rest_framework.authentication import SessionAuthentication
@@ -15,14 +17,25 @@
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.courseware.access import get_user_role
- from lms.djangoapps.courseware.toggles import learning_assistant_is_active
except ImportError:
- # If the waffle flag is false, the endpoint will force an early return.
- learning_assistant_is_active = False
+ CourseMode = None
+ CourseEnrollment = None
+ get_user_role = None
-from learning_assistant.api import get_setup_messages
+from learning_assistant.api import (
+ audit_trial_is_expired,
+ get_audit_trial,
+ get_course_id,
+ get_message_history,
+ get_or_create_audit_trial,
+ learning_assistant_enabled,
+ render_prompt_template,
+ save_chat_message,
+)
+from learning_assistant.models import LearningAssistantMessage
from learning_assistant.serializers import MessageSerializer
-from learning_assistant.utils import get_chat_response
+from learning_assistant.toggles import chat_history_enabled
+from learning_assistant.utils import get_chat_response, user_role_is_staff
log = logging.getLogger(__name__)
@@ -30,14 +43,78 @@
class CourseChatView(APIView):
"""
View to retrieve chat response.
+
+ Accepts: [POST]
+
+ Path: /learning_assistant/v1/course_id/{course_run_id}
+
+ Parameters:
+ * course_run_id: the ID of the course
+
+ Responses:
+ * 200: OK
+ * 400: Malformed Request - Course ID is not a valid course ID.
+ * 403: Forbidden - Learning assistant not enabled for course or learner does not have a valid enrollment or is
+ not staff.
"""
authentication_classes = (SessionAuthentication, JwtAuthentication,)
permission_classes = (IsAuthenticated,)
- def post(self, request, course_id):
+ def _get_next_message(self, request, courserun_key, course_run_id):
+ """
+ Generate the next message to be returned by the learning assistant.
+ """
+ message_list = request.data
+
+ # Check that the last message in the list corresponds to a user
+ new_user_message = message_list[-1]
+ if new_user_message['role'] != LearningAssistantMessage.USER_ROLE:
+ return Response(
+ status=http_status.HTTP_400_BAD_REQUEST,
+ data={'detail': "Expects user role on last message."}
+ )
+
+ user_id = request.user.id
+
+ if chat_history_enabled(courserun_key):
+ save_chat_message(courserun_key, user_id, LearningAssistantMessage.USER_ROLE, new_user_message['content'])
+
+ serializer = MessageSerializer(data=message_list, many=True)
+
+ # serializer will not be valid in the case that the message list contains any roles other than
+ # `user` or `assistant`
+ if not serializer.is_valid():
+ return Response(
+ status=http_status.HTTP_400_BAD_REQUEST,
+ data={'detail': 'Invalid data', 'errors': serializer.errors}
+ )
+
+ log.info(
+ 'Attempting to retrieve chat response for user_id=%(user_id)s in course_id=%(course_id)s',
+ {
+ 'user_id': request.user.id,
+ 'course_id': course_run_id
+ }
+ )
+
+ course_id = get_course_id(course_run_id)
+ template_string = getattr(settings, 'LEARNING_ASSISTANT_PROMPT_TEMPLATE', '')
+ unit_id = request.query_params.get('unit_id')
+
+ prompt_template = render_prompt_template(
+ request, request.user.id, course_run_id, unit_id, course_id, template_string
+ )
+ status_code, message = get_chat_response(prompt_template, message_list)
+
+ if chat_history_enabled(courserun_key):
+ save_chat_message(courserun_key, user_id, LearningAssistantMessage.ASSISTANT_ROLE, message['content'])
+
+ return Response(status=status_code, data=message)
+
+ def post(self, request, course_run_id):
"""
- Given a course ID, retrieve a chat response for that course.
+ Given a course run ID, retrieve a chat response for that course.
Expected POST data: {
[
@@ -46,54 +123,290 @@ def post(self, request, course_id):
]
}
"""
- course_key = CourseKey.from_string(course_id)
- if not learning_assistant_is_active(course_key):
+ try:
+ courserun_key = CourseKey.from_string(course_run_id)
+ except InvalidKeyError:
+ return Response(
+ status=http_status.HTTP_400_BAD_REQUEST,
+ data={'detail': 'Course ID is not a valid course ID.'}
+ )
+
+ if not learning_assistant_enabled(courserun_key):
return Response(
status=http_status.HTTP_403_FORBIDDEN,
data={'detail': 'Learning assistant not enabled for course.'}
)
- # If user does not have a verified enrollment, or is not staff, they should not have access
- user_role = get_user_role(request.user, course_key)
- enrollment_object = CourseEnrollment.get_enrollment(request.user, course_key)
+ # If user does not have a verified enrollment record, or is not staff, they should not have full access
+ user_role = get_user_role(request.user, courserun_key)
+ enrollment_object = CourseEnrollment.get_enrollment(request.user, courserun_key)
enrollment_mode = enrollment_object.mode if enrollment_object else None
+
+ # If the user is in a verified course mode or is staff, return the next message
if (
- (enrollment_mode not in CourseMode.VERIFIED_MODES)
- and user_role not in ('staff', 'instructor')
+ # Here we include CREDIT_MODES and NO_ID_PROFESSIONAL_MODE, as CourseMode.VERIFIED_MODES on its own
+ # doesn't match what we count as "verified modes" in the frontend component.
+ enrollment_mode in CourseMode.VERIFIED_MODES + CourseMode.CREDIT_MODES
+ + [CourseMode.NO_ID_PROFESSIONAL_MODE]
+ or user_role_is_staff(user_role)
):
+ return self._get_next_message(request, courserun_key, course_run_id)
+
+ # If user has an audit enrollment record, get or create their trial. If the trial is not expired, return the
+ # next message. Otherwise, return 403
+ elif enrollment_mode in CourseMode.UPSELL_TO_VERIFIED_MODES: # AUDIT, HONOR
+ audit_trial = get_or_create_audit_trial(request.user)
+ is_user_audit_trial_expired = audit_trial_is_expired(audit_trial, courserun_key)
+ if is_user_audit_trial_expired:
+ return Response(
+ status=http_status.HTTP_403_FORBIDDEN,
+ data={'detail': 'The audit trial for this user has expired.'}
+ )
+ else:
+ return self._get_next_message(request, courserun_key, course_run_id)
+
+ # If user has a course mode that is not verified & not meant to access to the learning assistant, return 403
+ # This covers the other course modes: UNPAID_EXECUTIVE_EDUCATION & UNPAID_BOOTCAMP
+ else:
return Response(
status=http_status.HTTP_403_FORBIDDEN,
data={'detail': 'Must be staff or have valid enrollment.'}
)
- prompt_messages = get_setup_messages(course_id)
- if not prompt_messages:
+
+class LearningAssistantEnabledView(APIView):
+ """
+ View to retrieve whether the Learning Assistant is enabled for a course.
+
+ This endpoint returns a boolean representing whether the Learning Assistant feature is enabled in a course
+ represented by the course_run_id, which is provided in the URL.
+
+ Accepts: [GET]
+
+ Path: /learning_assistant/v1/course_id/{course_run_id}/enabled
+
+ Parameters:
+ * course_run_id: the ID of the course
+
+ Responses:
+ * 200: OK
+ * 400: Malformed Request - Course ID is not a valid course ID.
+ """
+
+ authentication_classes = (SessionAuthentication, JwtAuthentication,)
+ permission_classes = (IsAuthenticated,)
+
+ def get(self, request, course_run_id):
+ """
+ Given a course run ID, retrieve whether the Learning Assistant is enabled for the corresponding course.
+
+ The response will be in the following format.
+
+ {'enabled': }
+ """
+ try:
+ courserun_key = CourseKey.from_string(course_run_id)
+ except InvalidKeyError:
return Response(
- status=http_status.HTTP_404_NOT_FOUND,
- data={'detail': 'Learning assistant not enabled for course.'}
+ status=http_status.HTTP_400_BAD_REQUEST,
+ data={'detail': 'Course ID is not a valid course ID.'}
)
- message_list = request.data
- serializer = MessageSerializer(data=message_list, many=True)
+ data = {
+ 'enabled': learning_assistant_enabled(courserun_key),
+ }
- # serializer will not be valid in the case that the message list contains any roles other than
- # `user` or `assistant`
- if not serializer.is_valid():
+ return Response(status=http_status.HTTP_200_OK, data=data)
+
+
+class LearningAssistantMessageHistoryView(APIView):
+ """
+ View to retrieve the message history for user in a course.
+
+ This endpoint returns the message history stored in the LearningAssistantMessage table in a course
+ represented by the course_run_id, which is provided in the URL.
+
+ Accepts: [GET]
+
+ Path: /learning_assistant/v1/course_id/{course_run_id}/history
+
+ Parameters:
+ * course_run_id: the ID of the course
+
+ Responses:
+ * 200: OK
+ * 400: Malformed Request - Course ID is not a valid course ID.
+ * 403: Forbidden - Learning assistant not enabled for course or learner does not have a valid enrollment or is
+ not staff.
+ """
+
+ authentication_classes = (SessionAuthentication, JwtAuthentication,)
+ permission_classes = (IsAuthenticated,)
+
+ def get(self, request, course_run_id):
+ """
+ Given a course run ID, retrieve the message history for the corresponding user.
+
+ The response will be in the following format.
+
+ [{'role': 'assistant', 'content': 'something'}]
+ """
+ try:
+ courserun_key = CourseKey.from_string(course_run_id)
+ except InvalidKeyError:
return Response(
status=http_status.HTTP_400_BAD_REQUEST,
- data={'detail': 'Invalid data', 'errors': serializer.errors}
+ data={'detail': 'Course ID is not a valid course ID.'}
+ )
+
+ if not learning_assistant_enabled(courserun_key):
+ return Response(
+ status=http_status.HTTP_403_FORBIDDEN,
+ data={'detail': 'Learning assistant not enabled for course.'}
)
- # append system message to beginning of message list
- message_setup = prompt_messages
+ # If chat history is disabled, we return no messages as response.
+ if not chat_history_enabled(courserun_key):
+ return Response(status=http_status.HTTP_200_OK, data=[])
- log.info(
- 'Attempting to retrieve chat response for user_id=%(user_id)s in course_id=%(course_id)s',
- {
- 'user_id': request.user.id,
- 'course_id': course_id
+ # If user does not have an enrollment record, or is not staff, they should not have access
+ user_role = get_user_role(request.user, courserun_key)
+ enrollment_object = CourseEnrollment.get_enrollment(request.user, courserun_key)
+ enrollment_mode = enrollment_object.mode if enrollment_object else None
+ if (
+ (enrollment_mode not in CourseMode.VERIFIED_MODES)
+ and not user_role_is_staff(user_role)
+ ):
+ return Response(
+ status=http_status.HTTP_403_FORBIDDEN,
+ data={'detail': 'Must be staff or have valid enrollment.'}
+ )
+
+ user = request.user
+
+ message_count = int(request.GET.get('message_count', 50))
+ message_history = get_message_history(courserun_key, user, message_count)
+ data = MessageSerializer(message_history, many=True).data
+ return Response(status=http_status.HTTP_200_OK, data=data)
+
+
+class LearningAssistantChatSummaryView(APIView):
+ """
+ View to retrieve data about a learner's session with the Learning Assistant.
+
+ This endpoint returns all the data necessary for the Learning Assistant to function, including the following
+ information.
+ * whether the Learning Assistant is enabled
+ * message history information, if the learner is eligible to use the Learning Assistant
+ * audit trial information
+
+ Accepts: [GET]
+
+ Path: /learning_assistant/v1/course_id/{course_run_id}/chat-summary
+
+ Parameters:
+ * course_run_id: the ID of the course
+
+ Responses:
+ * 200: OK
+ * 400: Malformed Request - Course ID is not a valid course ID.
+ """
+
+ authentication_classes = (SessionAuthentication, JwtAuthentication,)
+ permission_classes = (IsAuthenticated,)
+
+ def get(self, request, course_run_id):
+ """
+ Given a course run ID, return all the data necessary for the Learning Assistant to fuction.
+
+ The response will be in the following format.
+
+ {
+ "enabled": true,
+ "message_history": [
+ {
+ "role": "user",
+ "content": "test message from user",
+ "timestamp": "2024-12-02T15:04:17.495928Z"
+ },
+ {
+ "role": "assistant",
+ "content": "test message from assistant",
+ "timestamp": "2024-12-02T15:04:40.084584Z"
+ }
+ ],
+ "trial": {
+ "start_date": "2024-12-02T14:59:16.148236Z",
+ "expiration_date": "2024-12-16T14:59:16.148236Z"
}
+ }
+ """
+ try:
+ courserun_key = CourseKey.from_string(course_run_id)
+ except InvalidKeyError:
+ return Response(
+ status=http_status.HTTP_400_BAD_REQUEST,
+ data={'detail': 'Course ID is not a valid course ID.'}
+ )
+
+ data = {}
+ user = request.user
+
+ # Get whether the Learning Assistant is enabled.
+ data['enabled'] = learning_assistant_enabled(courserun_key)
+
+ # Get message history.
+ # If user does not have a verified enrollment record or is does not have an active audit trial, or is not staff,
+ # then they should not have access to the message history.
+ user_role = get_user_role(user, courserun_key)
+ enrollment_object = CourseEnrollment.get_enrollment(request.user, courserun_key)
+ enrollment_mode = enrollment_object.mode if enrollment_object else None
+
+ # Here we include CREDIT_MODES and NO_ID_PROFESSIONAL_MODE, as CourseMode.VERIFIED_MODES on its own
+ # doesn't match what we count as "verified modes" in the frontend component. We also include AUDIT and HONOR to
+ # ensure learners with audit trials see message history if the trial is non-expired.
+ valid_full_access_modes = (
+ CourseMode.VERIFIED_MODES +
+ CourseMode.CREDIT_MODES +
+ [CourseMode.NO_ID_PROFESSIONAL_MODE]
)
- status_code, message = get_chat_response(message_setup + message_list)
+ valid_trial_access_modes = CourseMode.UPSELL_TO_VERIFIED_MODES
- return Response(status=status_code, data=message)
+ # Get audit trial. Note that we do not want to create an audit trial when calling this endpoint.
+ audit_trial = get_audit_trial(request.user)
+
+ # If the learner doesn't meet criteria to use the Learning Assistant, or if the chat history is disabled, we
+ # return no messages in the response.
+ message_history_data = []
+
+ has_trial_access = (
+ enrollment_mode in valid_trial_access_modes
+ and audit_trial
+ and not audit_trial_is_expired(audit_trial, courserun_key)
+ )
+
+ if (
+ (
+ (enrollment_mode in valid_full_access_modes)
+ or has_trial_access
+ or user_role_is_staff(user_role)
+ )
+ and chat_history_enabled(courserun_key)
+ ):
+ message_count = int(request.GET.get('message_count', 50))
+ message_history = get_message_history(courserun_key, user, message_count)
+ message_history_data = MessageSerializer(message_history, many=True).data
+
+ data['message_history'] = message_history_data
+
+ # Get audit trial.
+ trial = get_audit_trial(user)
+
+ trial_data = {}
+ if trial:
+ trial_data['start_date'] = trial.start_date
+ trial_data['expiration_date'] = trial.expiration_date
+
+ data['audit_trial'] = trial_data
+
+ return Response(status=http_status.HTTP_200_OK, data=data)
diff --git a/pylintrc b/pylintrc
index 8d94efc..a6edcf3 100644
--- a/pylintrc
+++ b/pylintrc
@@ -2,7 +2,7 @@
# ** DO NOT EDIT THIS FILE **
# ***************************
#
-# This file was generated by edx-lint: https://github.com/edx/edx-lint
+# This file was generated by edx-lint: https://github.com/openedx/edx-lint
#
# If you want to change this file, you have two choices, depending on whether
# you want to make a local change that applies only to this repo, or whether
@@ -28,7 +28,7 @@
# CENTRAL CHANGE:
#
# 1. Edit the pylintrc file in the edx-lint repo at
-# https://github.com/edx/edx-lint/blob/master/edx_lint/files/pylintrc
+# https://github.com/openedx/edx-lint/blob/master/edx_lint/files/pylintrc
#
# 2. install the updated version of edx-lint (in edx-lint):
#
@@ -64,7 +64,7 @@
# SERIOUSLY.
#
# ------------------------------
-# Generated by edx-lint version: 5.2.4
+# Generated by edx-lint version: 5.4.0
# ------------------------------
[MASTER]
ignore = migrations
@@ -259,6 +259,7 @@ enable =
useless-suppression,
disable =
bad-indentation,
+ broad-exception-raised,
consider-using-f-string,
duplicate-code,
file-ignored,
@@ -290,6 +291,8 @@ disable =
django-not-configured,
consider-using-with,
bad-option-value,
+ import-error,
+ too-many-positional-arguments,
[REPORTS]
output-format = text
@@ -384,6 +387,6 @@ ext-import-graph =
int-import-graph =
[EXCEPTIONS]
-overgeneral-exceptions = Exception
+overgeneral-exceptions = builtins.Exception
-# d103a3939f8137bacf02447b6f210552aa7f2827
+# f8335666f17965c1c4bba3fc53bf25a343eaf3bf
diff --git a/pylintrc_tweaks b/pylintrc_tweaks
index 7b6eb35..9076787 100644
--- a/pylintrc_tweaks
+++ b/pylintrc_tweaks
@@ -9,3 +9,5 @@ disable+=
django-not-configured,
consider-using-with,
bad-option-value,
+ import-error,
+ too-many-positional-arguments,
diff --git a/requirements/base.in b/requirements/base.in
index b11b471..0fc7801 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -1,8 +1,11 @@
# Core requirements for using this application
-c constraints.txt
+attrs
Django # Web application framework
django-model-utils
djangorestframework
edx-drf-extensions
+edx-rest-api-client
edx-opaque-keys
+jinja2
diff --git a/requirements/base.txt b/requirements/base.txt
index 4d944b2..1fb82ad 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,24 +1,26 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-asgiref==3.7.2
+asgiref==3.8.1
# via django
-certifi==2023.7.22
+attrs==24.2.0
+ # via -r requirements/base.in
+certifi==2024.8.30
# via requests
-cffi==1.15.1
+cffi==1.17.1
# via
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.4.0
# via requests
click==8.1.7
# via edx-django-utils
-cryptography==41.0.3
+cryptography==43.0.3
# via pyjwt
-django==3.2.20
+django==4.2.16
# via
# -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
# -r requirements/base.in
@@ -31,68 +33,69 @@ django==3.2.20
# edx-drf-extensions
django-crum==0.7.9
# via edx-django-utils
-django-model-utils==4.3.1
+django-model-utils==5.0.0
# via -r requirements/base.in
-django-waffle==4.0.0
+django-waffle==4.1.0
# via
# edx-django-utils
# edx-drf-extensions
-djangorestframework==3.14.0
+djangorestframework==3.15.2
# via
# -r requirements/base.in
# drf-jwt
# edx-drf-extensions
+dnspython==2.7.0
+ # via pymongo
drf-jwt==1.19.2
# via edx-drf-extensions
-edx-django-utils==5.7.0
- # via edx-drf-extensions
-edx-drf-extensions==8.9.1
+edx-django-utils==7.0.0
+ # via
+ # edx-drf-extensions
+ # edx-rest-api-client
+edx-drf-extensions==10.5.0
# via -r requirements/base.in
-edx-opaque-keys==2.5.0
+edx-opaque-keys==2.11.0
# via
# -r requirements/base.in
# edx-drf-extensions
-idna==3.4
+edx-rest-api-client==6.0.0
+ # via -r requirements/base.in
+idna==3.10
# via requests
-newrelic==9.0.0
+jinja2==3.1.4
+ # via -r requirements/base.in
+markupsafe==3.0.2
+ # via jinja2
+newrelic==10.2.0
# via edx-django-utils
-pbr==5.11.1
+pbr==6.1.0
# via stevedore
-psutil==5.9.5
+psutil==6.1.0
# via edx-django-utils
-pycparser==2.21
+pycparser==2.22
# via cffi
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# drf-jwt
# edx-drf-extensions
-pymongo==3.13.0
+ # edx-rest-api-client
+pymongo==4.10.1
# via edx-opaque-keys
pynacl==1.5.0
# via edx-django-utils
-python-dateutil==2.8.2
- # via edx-drf-extensions
-pytz==2023.3
+requests==2.32.3
# via
- # django
- # djangorestframework
-requests==2.31.0
- # via edx-drf-extensions
+ # edx-drf-extensions
+ # edx-rest-api-client
semantic-version==2.10.0
# via edx-drf-extensions
-six==1.16.0
- # via
- # edx-drf-extensions
- # python-dateutil
-sqlparse==0.4.4
+sqlparse==0.5.2
# via django
-stevedore==5.1.0
+stevedore==5.3.0
# via
# edx-django-utils
# edx-opaque-keys
-typing-extensions==4.7.1
- # via
- # asgiref
- # edx-opaque-keys
-urllib3==2.0.4
+typing-extensions==4.12.2
+ # via edx-opaque-keys
+urllib3==2.2.3
# via requests
diff --git a/requirements/ci.in b/requirements/ci.in
index a99051b..3586cbe 100644
--- a/requirements/ci.in
+++ b/requirements/ci.in
@@ -2,6 +2,4 @@
-c constraints.txt
-codecov # Code coverage reporting
tox # Virtualenv management for tests
-tox-battery # Makes tox aware of requirements file changes
diff --git a/requirements/ci.txt b/requirements/ci.txt
index d493b38..e128790 100644
--- a/requirements/ci.txt
+++ b/requirements/ci.txt
@@ -1,49 +1,34 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-certifi==2023.7.22
- # via requests
-charset-normalizer==3.2.0
- # via requests
-codecov==2.1.13
- # via -r requirements/ci.in
-coverage==7.3.0
- # via codecov
-distlib==0.3.7
+cachetools==5.5.0
+ # via tox
+chardet==5.2.0
+ # via tox
+colorama==0.4.6
+ # via tox
+distlib==0.3.9
# via virtualenv
-filelock==3.12.3
+filelock==3.16.1
# via
# tox
# virtualenv
-idna==3.4
- # via requests
-packaging==23.1
- # via tox
-platformdirs==3.10.0
- # via virtualenv
-pluggy==1.3.0
- # via tox
-py==1.11.0
- # via tox
-requests==2.31.0
- # via codecov
-six==1.16.0
+packaging==24.2
+ # via
+ # pyproject-api
+ # tox
+platformdirs==4.3.6
+ # via
+ # tox
+ # virtualenv
+pluggy==1.5.0
# via tox
-tomli==2.0.1
+pyproject-api==1.8.0
# via tox
-tox==3.28.0
- # via
- # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
- # -r requirements/ci.in
- # tox-battery
-tox-battery==0.6.2
+tox==4.23.2
# via -r requirements/ci.in
-typing-extensions==4.7.1
- # via filelock
-urllib3==2.0.4
- # via requests
-virtualenv==20.24.3
+virtualenv==20.27.1
# via tox
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 9e0b081..5b600f6 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -1,37 +1,44 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-asgiref==3.7.2
+asgiref==3.8.1
# via
# -r requirements/quality.txt
# django
-astroid==2.15.6
+astroid==3.3.5
# via
# -r requirements/quality.txt
# pylint
# pylint-celery
-build==0.10.0
+attrs==24.2.0
+ # via -r requirements/quality.txt
+build==1.2.2.post1
# via
# -r requirements/pip-tools.txt
# pip-tools
-certifi==2023.7.22
+cachetools==5.5.0
# via
# -r requirements/ci.txt
+ # tox
+certifi==2024.8.30
+ # via
# -r requirements/quality.txt
# requests
-cffi==1.15.1
+cffi==1.17.1
# via
# -r requirements/quality.txt
# cryptography
# pynacl
chardet==5.2.0
- # via diff-cover
-charset-normalizer==3.2.0
# via
# -r requirements/ci.txt
+ # diff-cover
+ # tox
+charset-normalizer==3.4.0
+ # via
# -r requirements/quality.txt
# requests
click==8.1.7
@@ -47,35 +54,35 @@ click-log==0.4.0
# via
# -r requirements/quality.txt
# edx-lint
-code-annotations==1.5.0
+code-annotations==1.8.1
# via
# -r requirements/quality.txt
# edx-lint
-codecov==2.1.13
- # via -r requirements/ci.txt
-coverage[toml]==7.3.0
+colorama==0.4.6
# via
# -r requirements/ci.txt
+ # tox
+coverage[toml]==7.6.7
+ # via
# -r requirements/quality.txt
- # codecov
# pytest-cov
-cryptography==41.0.3
+cryptography==43.0.3
# via
# -r requirements/quality.txt
# pyjwt
-ddt==1.6.0
+ddt==1.7.2
# via -r requirements/quality.txt
-diff-cover==7.7.0
+diff-cover==9.2.0
# via -r requirements/dev.in
-dill==0.3.7
+dill==0.3.9
# via
# -r requirements/quality.txt
# pylint
-distlib==0.3.7
+distlib==0.3.9
# via
# -r requirements/ci.txt
# virtualenv
-django==3.2.20
+django==4.2.16
# via
# -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
# -r requirements/quality.txt
@@ -91,68 +98,74 @@ django-crum==0.7.9
# via
# -r requirements/quality.txt
# edx-django-utils
-django-model-utils==4.3.1
+django-model-utils==5.0.0
# via -r requirements/quality.txt
-django-waffle==4.0.0
+django-waffle==4.1.0
# via
# -r requirements/quality.txt
# edx-django-utils
# edx-drf-extensions
-djangorestframework==3.14.0
+djangorestframework==3.15.2
# via
# -r requirements/quality.txt
# drf-jwt
# edx-drf-extensions
+dnspython==2.7.0
+ # via
+ # -r requirements/quality.txt
+ # pymongo
drf-jwt==1.19.2
# via
# -r requirements/quality.txt
# edx-drf-extensions
-edx-django-utils==5.7.0
+edx-django-utils==7.0.0
# via
# -r requirements/quality.txt
# edx-drf-extensions
-edx-drf-extensions==8.9.1
+ # edx-rest-api-client
+edx-drf-extensions==10.5.0
# via -r requirements/quality.txt
-edx-i18n-tools==1.1.0
+edx-i18n-tools==1.6.3
# via -r requirements/dev.in
-edx-lint==5.3.4
+edx-lint==5.4.1
# via -r requirements/quality.txt
-edx-opaque-keys==2.5.0
+edx-opaque-keys==2.11.0
# via
# -r requirements/quality.txt
# edx-drf-extensions
-exceptiongroup==1.1.3
- # via
- # -r requirements/quality.txt
- # pytest
-filelock==3.12.3
+edx-rest-api-client==6.0.0
+ # via -r requirements/quality.txt
+filelock==3.16.1
# via
# -r requirements/ci.txt
# tox
# virtualenv
-idna==3.4
+freezegun==1.5.1
+ # via -r requirements/quality.txt
+idna==3.10
# via
- # -r requirements/ci.txt
# -r requirements/quality.txt
# requests
iniconfig==2.0.0
# via
# -r requirements/quality.txt
# pytest
-isort==5.12.0
+isort==5.13.2
# via
# -r requirements/quality.txt
# pylint
-jinja2==3.1.2
+jinja2==3.1.4
# via
# -r requirements/quality.txt
# code-annotations
# diff-cover
-lazy-object-proxy==1.9.0
+lxml[html-clean,html_clean]==5.3.0
# via
- # -r requirements/quality.txt
- # astroid
-markupsafe==2.1.3
+ # edx-i18n-tools
+ # lxml-html-clean
+lxml-html-clean==0.4.1
+ # via lxml
+markupsafe==3.0.2
# via
# -r requirements/quality.txt
# jinja2
@@ -160,33 +173,35 @@ mccabe==0.7.0
# via
# -r requirements/quality.txt
# pylint
-newrelic==9.0.0
+newrelic==10.2.0
# via
# -r requirements/quality.txt
# edx-django-utils
-packaging==23.1
+packaging==24.2
# via
# -r requirements/ci.txt
# -r requirements/pip-tools.txt
# -r requirements/quality.txt
# build
+ # pyproject-api
# pytest
# tox
-path==16.7.1
+path==16.16.0
# via edx-i18n-tools
-pbr==5.11.1
+pbr==6.1.0
# via
# -r requirements/quality.txt
# stevedore
-pip-tools==7.3.0
+pip-tools==7.4.1
# via -r requirements/pip-tools.txt
-platformdirs==3.10.0
+platformdirs==4.3.6
# via
# -r requirements/ci.txt
# -r requirements/quality.txt
# pylint
+ # tox
# virtualenv
-pluggy==1.3.0
+pluggy==1.5.0
# via
# -r requirements/ci.txt
# -r requirements/quality.txt
@@ -195,30 +210,27 @@ pluggy==1.3.0
# tox
polib==1.2.0
# via edx-i18n-tools
-psutil==5.9.5
+psutil==6.1.0
# via
# -r requirements/quality.txt
# edx-django-utils
-py==1.11.0
- # via
- # -r requirements/ci.txt
- # tox
-pycodestyle==2.11.0
+pycodestyle==2.12.1
# via -r requirements/quality.txt
-pycparser==2.21
+pycparser==2.22
# via
# -r requirements/quality.txt
# cffi
pydocstyle==6.3.0
# via -r requirements/quality.txt
-pygments==2.16.1
+pygments==2.18.0
# via diff-cover
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/quality.txt
# drf-jwt
# edx-drf-extensions
-pylint==2.17.5
+ # edx-rest-api-client
+pylint==3.3.1
# via
# -r requirements/quality.txt
# edx-lint
@@ -229,7 +241,7 @@ pylint-celery==0.3
# via
# -r requirements/quality.txt
# edx-lint
-pylint-django==2.5.3
+pylint-django==2.6.1
# via
# -r requirements/quality.txt
# edx-lint
@@ -238,7 +250,7 @@ pylint-plugin-utils==0.8.2
# -r requirements/quality.txt
# pylint-celery
# pylint-django
-pymongo==3.13.0
+pymongo==4.10.1
# via
# -r requirements/quality.txt
# edx-opaque-keys
@@ -246,46 +258,45 @@ pynacl==1.5.0
# via
# -r requirements/quality.txt
# edx-django-utils
-pyproject-hooks==1.0.0
+pyproject-api==1.8.0
+ # via
+ # -r requirements/ci.txt
+ # tox
+pyproject-hooks==1.2.0
# via
# -r requirements/pip-tools.txt
# build
-pytest==7.4.0
+ # pip-tools
+pytest==8.3.3
# via
# -r requirements/quality.txt
# pytest-cov
# pytest-django
-pytest-cov==4.1.0
+pytest-cov==6.0.0
# via -r requirements/quality.txt
-pytest-django==4.5.2
+pytest-django==4.9.0
# via -r requirements/quality.txt
-python-dateutil==2.8.2
+python-dateutil==2.9.0.post0
# via
# -r requirements/quality.txt
- # edx-drf-extensions
-python-slugify==8.0.1
+ # freezegun
+python-slugify==8.0.4
# via
# -r requirements/quality.txt
# code-annotations
-pytz==2023.3
- # via
- # -r requirements/quality.txt
- # django
- # djangorestframework
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -r requirements/quality.txt
# code-annotations
# edx-i18n-tools
# responses
-requests==2.31.0
+requests==2.32.3
# via
- # -r requirements/ci.txt
# -r requirements/quality.txt
- # codecov
# edx-drf-extensions
+ # edx-rest-api-client
# responses
-responses==0.23.3
+responses==0.25.3
# via -r requirements/quality.txt
semantic-version==2.10.0
# via
@@ -293,21 +304,18 @@ semantic-version==2.10.0
# edx-drf-extensions
six==1.16.0
# via
- # -r requirements/ci.txt
# -r requirements/quality.txt
- # edx-drf-extensions
# edx-lint
# python-dateutil
- # tox
snowballstemmer==2.2.0
# via
# -r requirements/quality.txt
# pydocstyle
-sqlparse==0.4.4
+sqlparse==0.5.2
# via
# -r requirements/quality.txt
# django
-stevedore==5.1.0
+stevedore==5.3.0
# via
# -r requirements/quality.txt
# code-annotations
@@ -317,60 +325,29 @@ text-unidecode==1.3
# via
# -r requirements/quality.txt
# python-slugify
-tomli==2.0.1
- # via
- # -r requirements/ci.txt
- # -r requirements/pip-tools.txt
- # -r requirements/quality.txt
- # build
- # coverage
- # pip-tools
- # pylint
- # pyproject-hooks
- # pytest
- # tox
-tomlkit==0.12.1
+tomlkit==0.13.2
# via
# -r requirements/quality.txt
# pylint
-tox==3.28.0
- # via
- # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
- # -r requirements/ci.txt
- # tox-battery
-tox-battery==0.6.2
+tox==4.23.2
# via -r requirements/ci.txt
-types-pyyaml==6.0.12.11
- # via
- # -r requirements/quality.txt
- # responses
-typing-extensions==4.7.1
+typing-extensions==4.12.2
# via
- # -r requirements/ci.txt
# -r requirements/quality.txt
- # asgiref
- # astroid
# edx-opaque-keys
- # filelock
- # pylint
-urllib3==2.0.4
+urllib3==2.2.3
# via
- # -r requirements/ci.txt
# -r requirements/quality.txt
# requests
# responses
-virtualenv==20.24.3
+virtualenv==20.27.1
# via
# -r requirements/ci.txt
# tox
-wheel==0.41.2
+wheel==0.45.0
# via
# -r requirements/pip-tools.txt
# pip-tools
-wrapt==1.15.0
- # via
- # -r requirements/quality.txt
- # astroid
# The following packages are considered to be unsafe in a requirements file:
# pip
diff --git a/requirements/doc.txt b/requirements/doc.txt
index b08530e..8bd5308 100644
--- a/requirements/doc.txt
+++ b/requirements/doc.txt
@@ -1,31 +1,33 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-alabaster==0.7.13
+alabaster==1.0.0
# via sphinx
-asgiref==3.7.2
+asgiref==3.8.1
# via
# -r requirements/test.txt
# django
-babel==2.12.1
+attrs==24.2.0
+ # via -r requirements/test.txt
+babel==2.16.0
# via sphinx
-bleach==6.0.0
- # via readme-renderer
-build==0.10.0
+backports-tarfile==1.2.0
+ # via jaraco-context
+build==1.2.2.post1
# via -r requirements/doc.in
-certifi==2023.7.22
+certifi==2024.8.30
# via
# -r requirements/test.txt
# requests
-cffi==1.15.1
+cffi==1.17.1
# via
# -r requirements/test.txt
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.4.0
# via
# -r requirements/test.txt
# requests
@@ -34,20 +36,19 @@ click==8.1.7
# -r requirements/test.txt
# code-annotations
# edx-django-utils
-code-annotations==1.5.0
+code-annotations==1.8.1
# via -r requirements/test.txt
-coverage[toml]==7.3.0
+coverage[toml]==7.6.7
# via
# -r requirements/test.txt
# pytest-cov
-cryptography==41.0.3
+cryptography==43.0.3
# via
# -r requirements/test.txt
# pyjwt
- # secretstorage
-ddt==1.6.0
+ddt==1.7.2
# via -r requirements/test.txt
-django==3.2.20
+django==4.2.16
# via
# -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
# -r requirements/test.txt
@@ -62,21 +63,25 @@ django-crum==0.7.9
# via
# -r requirements/test.txt
# edx-django-utils
-django-model-utils==4.3.1
+django-model-utils==5.0.0
# via -r requirements/test.txt
-django-waffle==4.0.0
+django-waffle==4.1.0
# via
# -r requirements/test.txt
# edx-django-utils
# edx-drf-extensions
-djangorestframework==3.14.0
+djangorestframework==3.15.2
# via
# -r requirements/test.txt
# drf-jwt
# edx-drf-extensions
-doc8==1.1.1
+dnspython==2.7.0
+ # via
+ # -r requirements/test.txt
+ # pymongo
+doc8==1.1.2
# via -r requirements/doc.in
-docutils==0.20.1
+docutils==0.21.2
# via
# doc8
# readme-renderer
@@ -86,100 +91,103 @@ drf-jwt==1.19.2
# via
# -r requirements/test.txt
# edx-drf-extensions
-edx-django-utils==5.7.0
+edx-django-utils==7.0.0
# via
# -r requirements/test.txt
# edx-drf-extensions
-edx-drf-extensions==8.9.1
+ # edx-rest-api-client
+edx-drf-extensions==10.5.0
# via -r requirements/test.txt
-edx-opaque-keys==2.5.0
+edx-opaque-keys==2.11.0
# via
# -r requirements/test.txt
# edx-drf-extensions
-exceptiongroup==1.1.3
- # via
- # -r requirements/test.txt
- # pytest
-idna==3.4
+edx-rest-api-client==6.0.0
+ # via -r requirements/test.txt
+freezegun==1.5.1
+ # via -r requirements/test.txt
+idna==3.10
# via
# -r requirements/test.txt
# requests
imagesize==1.4.1
# via sphinx
-importlib-metadata==6.8.0
+importlib-metadata==8.5.0
# via
# keyring
- # sphinx
# twine
-importlib-resources==6.0.1
- # via keyring
iniconfig==2.0.0
# via
# -r requirements/test.txt
# pytest
-jaraco-classes==3.3.0
+jaraco-classes==3.4.0
# via keyring
-jeepney==0.8.0
- # via
- # keyring
- # secretstorage
-jinja2==3.1.2
+jaraco-context==6.0.1
+ # via keyring
+jaraco-functools==4.1.0
+ # via keyring
+jinja2==3.1.4
# via
# -r requirements/test.txt
# code-annotations
# sphinx
-keyring==24.2.0
+keyring==25.5.0
# via twine
markdown-it-py==3.0.0
# via rich
-markupsafe==2.1.3
+markupsafe==3.0.2
# via
# -r requirements/test.txt
# jinja2
mdurl==0.1.2
# via markdown-it-py
-more-itertools==10.1.0
- # via jaraco-classes
-newrelic==9.0.0
+more-itertools==10.5.0
+ # via
+ # jaraco-classes
+ # jaraco-functools
+newrelic==10.2.0
# via
# -r requirements/test.txt
# edx-django-utils
-packaging==23.1
+nh3==0.2.18
+ # via readme-renderer
+packaging==24.2
# via
# -r requirements/test.txt
# build
# pytest
# sphinx
-pbr==5.11.1
+pbr==6.1.0
# via
# -r requirements/test.txt
# stevedore
-pkginfo==1.9.6
+pkginfo==1.10.0
# via twine
-pluggy==1.3.0
+pluggy==1.5.0
# via
# -r requirements/test.txt
# pytest
-psutil==5.9.5
+psutil==6.1.0
# via
# -r requirements/test.txt
# edx-django-utils
-pycparser==2.21
+pycparser==2.22
# via
# -r requirements/test.txt
# cffi
-pygments==2.16.1
+pygments==2.18.0
# via
# doc8
# readme-renderer
# rich
# sphinx
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/test.txt
# drf-jwt
# edx-drf-extensions
-pymongo==3.13.0
+ # edx-rest-api-client
+pymongo==4.10.1
# via
# -r requirements/test.txt
# edx-opaque-keys
@@ -187,58 +195,51 @@ pynacl==1.5.0
# via
# -r requirements/test.txt
# edx-django-utils
-pyproject-hooks==1.0.0
+pyproject-hooks==1.2.0
# via build
-pytest==7.4.0
+pytest==8.3.3
# via
# -r requirements/test.txt
# pytest-cov
# pytest-django
-pytest-cov==4.1.0
+pytest-cov==6.0.0
# via -r requirements/test.txt
-pytest-django==4.5.2
+pytest-django==4.9.0
# via -r requirements/test.txt
-python-dateutil==2.8.2
+python-dateutil==2.9.0.post0
# via
# -r requirements/test.txt
- # edx-drf-extensions
-python-slugify==8.0.1
+ # freezegun
+python-slugify==8.0.4
# via
# -r requirements/test.txt
# code-annotations
-pytz==2023.3
- # via
- # -r requirements/test.txt
- # babel
- # django
- # djangorestframework
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -r requirements/test.txt
# code-annotations
# responses
-readme-renderer==41.0
+readme-renderer==44.0
# via twine
-requests==2.31.0
+requests==2.32.3
# via
# -r requirements/test.txt
# edx-drf-extensions
+ # edx-rest-api-client
# requests-toolbelt
# responses
# sphinx
# twine
requests-toolbelt==1.0.0
# via twine
-responses==0.23.3
+responses==0.25.3
# via -r requirements/test.txt
restructuredtext-lint==1.4.0
# via doc8
rfc3986==2.0.0
# via twine
-rich==13.5.2
+rich==13.9.4
# via twine
-secretstorage==3.3.3
- # via keyring
semantic-version==2.10.0
# via
# -r requirements/test.txt
@@ -246,30 +247,28 @@ semantic-version==2.10.0
six==1.16.0
# via
# -r requirements/test.txt
- # bleach
- # edx-drf-extensions
# python-dateutil
snowballstemmer==2.2.0
# via sphinx
-sphinx==7.1.2
+sphinx==8.1.3
# via -r requirements/doc.in
-sphinxcontrib-applehelp==1.0.4
+sphinxcontrib-applehelp==2.0.0
# via sphinx
-sphinxcontrib-devhelp==1.0.2
+sphinxcontrib-devhelp==2.0.0
# via sphinx
-sphinxcontrib-htmlhelp==2.0.1
+sphinxcontrib-htmlhelp==2.1.0
# via sphinx
sphinxcontrib-jsmath==1.0.1
# via sphinx
-sphinxcontrib-qthelp==1.0.3
+sphinxcontrib-qthelp==2.0.0
# via sphinx
-sphinxcontrib-serializinghtml==1.1.5
+sphinxcontrib-serializinghtml==2.0.0
# via sphinx
-sqlparse==0.4.4
+sqlparse==0.5.2
# via
# -r requirements/test.txt
# django
-stevedore==5.1.0
+stevedore==5.3.0
# via
# -r requirements/test.txt
# code-annotations
@@ -280,35 +279,17 @@ text-unidecode==1.3
# via
# -r requirements/test.txt
# python-slugify
-tomli==2.0.1
- # via
- # -r requirements/test.txt
- # build
- # coverage
- # doc8
- # pyproject-hooks
- # pytest
-twine==4.0.2
+twine==5.1.1
# via -r requirements/doc.in
-types-pyyaml==6.0.12.11
- # via
- # -r requirements/test.txt
- # responses
-typing-extensions==4.7.1
+typing-extensions==4.12.2
# via
# -r requirements/test.txt
- # asgiref
# edx-opaque-keys
- # rich
-urllib3==2.0.4
+urllib3==2.2.3
# via
# -r requirements/test.txt
# requests
# responses
# twine
-webencodings==0.5.1
- # via bleach
-zipp==3.16.2
- # via
- # importlib-metadata
- # importlib-resources
+zipp==3.21.0
+ # via importlib-metadata
diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt
index 007ed38..dc539c5 100644
--- a/requirements/pip-tools.txt
+++ b/requirements/pip-tools.txt
@@ -1,25 +1,22 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-build==0.10.0
+build==1.2.2.post1
# via pip-tools
click==8.1.7
# via pip-tools
-packaging==23.1
+packaging==24.2
# via build
-pip-tools==7.3.0
+pip-tools==7.4.1
# via -r requirements/pip-tools.in
-pyproject-hooks==1.0.0
- # via build
-tomli==2.0.1
+pyproject-hooks==1.2.0
# via
# build
# pip-tools
- # pyproject-hooks
-wheel==0.41.2
+wheel==0.45.0
# via pip-tools
# The following packages are considered to be unsafe in a requirements file:
diff --git a/requirements/pip.txt b/requirements/pip.txt
index 13c7e84..e9be994 100644
--- a/requirements/pip.txt
+++ b/requirements/pip.txt
@@ -1,14 +1,16 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-wheel==0.41.2
+wheel==0.45.0
# via -r requirements/pip.in
# The following packages are considered to be unsafe in a requirements file:
-pip==23.2.1
- # via -r requirements/pip.in
-setuptools==68.1.2
+pip==24.2
+ # via
+ # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
+ # -r requirements/pip.in
+setuptools==75.5.0
# via -r requirements/pip.in
diff --git a/requirements/quality.txt b/requirements/quality.txt
index 64c97cc..ed97b2c 100644
--- a/requirements/quality.txt
+++ b/requirements/quality.txt
@@ -1,27 +1,29 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-asgiref==3.7.2
+asgiref==3.8.1
# via
# -r requirements/test.txt
# django
-astroid==2.15.6
+astroid==3.3.5
# via
# pylint
# pylint-celery
-certifi==2023.7.22
+attrs==24.2.0
+ # via -r requirements/test.txt
+certifi==2024.8.30
# via
# -r requirements/test.txt
# requests
-cffi==1.15.1
+cffi==1.17.1
# via
# -r requirements/test.txt
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.4.0
# via
# -r requirements/test.txt
# requests
@@ -34,23 +36,23 @@ click==8.1.7
# edx-lint
click-log==0.4.0
# via edx-lint
-code-annotations==1.5.0
+code-annotations==1.8.1
# via
# -r requirements/test.txt
# edx-lint
-coverage[toml]==7.3.0
+coverage[toml]==7.6.7
# via
# -r requirements/test.txt
# pytest-cov
-cryptography==41.0.3
+cryptography==43.0.3
# via
# -r requirements/test.txt
# pyjwt
-ddt==1.6.0
+ddt==1.7.2
# via -r requirements/test.txt
-dill==0.3.7
+dill==0.3.9
# via pylint
-django==3.2.20
+django==4.2.16
# via
# -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
# -r requirements/test.txt
@@ -65,39 +67,44 @@ django-crum==0.7.9
# via
# -r requirements/test.txt
# edx-django-utils
-django-model-utils==4.3.1
+django-model-utils==5.0.0
# via -r requirements/test.txt
-django-waffle==4.0.0
+django-waffle==4.1.0
# via
# -r requirements/test.txt
# edx-django-utils
# edx-drf-extensions
-djangorestframework==3.14.0
+djangorestframework==3.15.2
# via
# -r requirements/test.txt
# drf-jwt
# edx-drf-extensions
+dnspython==2.7.0
+ # via
+ # -r requirements/test.txt
+ # pymongo
drf-jwt==1.19.2
# via
# -r requirements/test.txt
# edx-drf-extensions
-edx-django-utils==5.7.0
+edx-django-utils==7.0.0
# via
# -r requirements/test.txt
# edx-drf-extensions
-edx-drf-extensions==8.9.1
+ # edx-rest-api-client
+edx-drf-extensions==10.5.0
# via -r requirements/test.txt
-edx-lint==5.3.4
+edx-lint==5.4.1
# via -r requirements/quality.in
-edx-opaque-keys==2.5.0
+edx-opaque-keys==2.11.0
# via
# -r requirements/test.txt
# edx-drf-extensions
-exceptiongroup==1.1.3
- # via
- # -r requirements/test.txt
- # pytest
-idna==3.4
+edx-rest-api-client==6.0.0
+ # via -r requirements/test.txt
+freezegun==1.5.1
+ # via -r requirements/test.txt
+idna==3.10
# via
# -r requirements/test.txt
# requests
@@ -105,58 +112,57 @@ iniconfig==2.0.0
# via
# -r requirements/test.txt
# pytest
-isort==5.12.0
+isort==5.13.2
# via
# -r requirements/quality.in
# pylint
-jinja2==3.1.2
+jinja2==3.1.4
# via
# -r requirements/test.txt
# code-annotations
-lazy-object-proxy==1.9.0
- # via astroid
-markupsafe==2.1.3
+markupsafe==3.0.2
# via
# -r requirements/test.txt
# jinja2
mccabe==0.7.0
# via pylint
-newrelic==9.0.0
+newrelic==10.2.0
# via
# -r requirements/test.txt
# edx-django-utils
-packaging==23.1
+packaging==24.2
# via
# -r requirements/test.txt
# pytest
-pbr==5.11.1
+pbr==6.1.0
# via
# -r requirements/test.txt
# stevedore
-platformdirs==3.10.0
+platformdirs==4.3.6
# via pylint
-pluggy==1.3.0
+pluggy==1.5.0
# via
# -r requirements/test.txt
# pytest
-psutil==5.9.5
+psutil==6.1.0
# via
# -r requirements/test.txt
# edx-django-utils
-pycodestyle==2.11.0
+pycodestyle==2.12.1
# via -r requirements/quality.in
-pycparser==2.21
+pycparser==2.22
# via
# -r requirements/test.txt
# cffi
pydocstyle==6.3.0
# via -r requirements/quality.in
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/test.txt
# drf-jwt
# edx-drf-extensions
-pylint==2.17.5
+ # edx-rest-api-client
+pylint==3.3.1
# via
# edx-lint
# pylint-celery
@@ -164,13 +170,13 @@ pylint==2.17.5
# pylint-plugin-utils
pylint-celery==0.3
# via edx-lint
-pylint-django==2.5.3
+pylint-django==2.6.1
# via edx-lint
pylint-plugin-utils==0.8.2
# via
# pylint-celery
# pylint-django
-pymongo==3.13.0
+pymongo==4.10.1
# via
# -r requirements/test.txt
# edx-opaque-keys
@@ -178,39 +184,35 @@ pynacl==1.5.0
# via
# -r requirements/test.txt
# edx-django-utils
-pytest==7.4.0
+pytest==8.3.3
# via
# -r requirements/test.txt
# pytest-cov
# pytest-django
-pytest-cov==4.1.0
+pytest-cov==6.0.0
# via -r requirements/test.txt
-pytest-django==4.5.2
+pytest-django==4.9.0
# via -r requirements/test.txt
-python-dateutil==2.8.2
+python-dateutil==2.9.0.post0
# via
# -r requirements/test.txt
- # edx-drf-extensions
-python-slugify==8.0.1
+ # freezegun
+python-slugify==8.0.4
# via
# -r requirements/test.txt
# code-annotations
-pytz==2023.3
- # via
- # -r requirements/test.txt
- # django
- # djangorestframework
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -r requirements/test.txt
# code-annotations
# responses
-requests==2.31.0
+requests==2.32.3
# via
# -r requirements/test.txt
# edx-drf-extensions
+ # edx-rest-api-client
# responses
-responses==0.23.3
+responses==0.25.3
# via -r requirements/test.txt
semantic-version==2.10.0
# via
@@ -219,16 +221,15 @@ semantic-version==2.10.0
six==1.16.0
# via
# -r requirements/test.txt
- # edx-drf-extensions
# edx-lint
# python-dateutil
snowballstemmer==2.2.0
# via pydocstyle
-sqlparse==0.4.4
+sqlparse==0.5.2
# via
# -r requirements/test.txt
# django
-stevedore==5.1.0
+stevedore==5.3.0
# via
# -r requirements/test.txt
# code-annotations
@@ -238,29 +239,14 @@ text-unidecode==1.3
# via
# -r requirements/test.txt
# python-slugify
-tomli==2.0.1
- # via
- # -r requirements/test.txt
- # coverage
- # pylint
- # pytest
-tomlkit==0.12.1
+tomlkit==0.13.2
# via pylint
-types-pyyaml==6.0.12.11
+typing-extensions==4.12.2
# via
# -r requirements/test.txt
- # responses
-typing-extensions==4.7.1
- # via
- # -r requirements/test.txt
- # asgiref
- # astroid
# edx-opaque-keys
- # pylint
-urllib3==2.0.4
+urllib3==2.2.3
# via
# -r requirements/test.txt
# requests
# responses
-wrapt==1.15.0
- # via astroid
diff --git a/requirements/test.in b/requirements/test.in
index 8b21fc7..bb09779 100644
--- a/requirements/test.in
+++ b/requirements/test.in
@@ -5,6 +5,7 @@
code-annotations # provides commands used by the pii_check make target.
ddt
+freezegun
pytest-cov # pytest extension for code coverage statistics
pytest-django # pytest extension for better Django support
responses
diff --git a/requirements/test.txt b/requirements/test.txt
index a19c5f8..ad6ed72 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,23 +1,25 @@
#
-# This file is autogenerated by pip-compile with Python 3.8
+# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# make upgrade
#
-asgiref==3.7.2
+asgiref==3.8.1
# via
# -r requirements/base.txt
# django
-certifi==2023.7.22
+attrs==24.2.0
+ # via -r requirements/base.txt
+certifi==2024.8.30
# via
# -r requirements/base.txt
# requests
-cffi==1.15.1
+cffi==1.17.1
# via
# -r requirements/base.txt
# cryptography
# pynacl
-charset-normalizer==3.2.0
+charset-normalizer==3.4.0
# via
# -r requirements/base.txt
# requests
@@ -26,15 +28,15 @@ click==8.1.7
# -r requirements/base.txt
# code-annotations
# edx-django-utils
-code-annotations==1.5.0
+code-annotations==1.8.1
# via -r requirements/test.in
-coverage[toml]==7.3.0
+coverage[toml]==7.6.7
# via pytest-cov
-cryptography==41.0.3
+cryptography==43.0.3
# via
# -r requirements/base.txt
# pyjwt
-ddt==1.6.0
+ddt==1.7.2
# via -r requirements/test.in
# via
# -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt
@@ -50,70 +52,82 @@ django-crum==0.7.9
# via
# -r requirements/base.txt
# edx-django-utils
-django-model-utils==4.3.1
+django-model-utils==5.0.0
# via -r requirements/base.txt
-django-waffle==4.0.0
+django-waffle==4.1.0
# via
# -r requirements/base.txt
# edx-django-utils
# edx-drf-extensions
-djangorestframework==3.14.0
+djangorestframework==3.15.2
# via
# -r requirements/base.txt
# drf-jwt
# edx-drf-extensions
+dnspython==2.7.0
+ # via
+ # -r requirements/base.txt
+ # pymongo
drf-jwt==1.19.2
# via
# -r requirements/base.txt
# edx-drf-extensions
-edx-django-utils==5.7.0
+edx-django-utils==7.0.0
# via
# -r requirements/base.txt
# edx-drf-extensions
-edx-drf-extensions==8.9.1
+ # edx-rest-api-client
+edx-drf-extensions==10.5.0
# via -r requirements/base.txt
-edx-opaque-keys==2.5.0
+edx-opaque-keys==2.11.0
# via
# -r requirements/base.txt
# edx-drf-extensions
-exceptiongroup==1.1.3
- # via pytest
-idna==3.4
+edx-rest-api-client==6.0.0
+ # via -r requirements/base.txt
+freezegun==1.5.1
+ # via -r requirements/test.in
+idna==3.10
# via
# -r requirements/base.txt
# requests
iniconfig==2.0.0
# via pytest
-jinja2==3.1.2
- # via code-annotations
-markupsafe==2.1.3
- # via jinja2
-newrelic==9.0.0
+jinja2==3.1.4
+ # via
+ # -r requirements/base.txt
+ # code-annotations
+markupsafe==3.0.2
+ # via
+ # -r requirements/base.txt
+ # jinja2
+newrelic==10.2.0
# via
# -r requirements/base.txt
# edx-django-utils
-packaging==23.1
+packaging==24.2
# via pytest
-pbr==5.11.1
+pbr==6.1.0
# via
# -r requirements/base.txt
# stevedore
-pluggy==1.3.0
+pluggy==1.5.0
# via pytest
-psutil==5.9.5
+psutil==6.1.0
# via
# -r requirements/base.txt
# edx-django-utils
-pycparser==2.21
+pycparser==2.22
# via
# -r requirements/base.txt
# cffi
-pyjwt[crypto]==2.8.0
+pyjwt[crypto]==2.9.0
# via
# -r requirements/base.txt
# drf-jwt
# edx-drf-extensions
-pymongo==3.13.0
+ # edx-rest-api-client
+pymongo==4.10.1
# via
# -r requirements/base.txt
# edx-opaque-keys
@@ -121,50 +135,41 @@ pynacl==1.5.0
# via
# -r requirements/base.txt
# edx-django-utils
-pytest==7.4.0
+pytest==8.3.3
# via
# pytest-cov
# pytest-django
-pytest-cov==4.1.0
+pytest-cov==6.0.0
# via -r requirements/test.in
-pytest-django==4.5.2
+pytest-django==4.9.0
# via -r requirements/test.in
-python-dateutil==2.8.2
- # via
- # -r requirements/base.txt
- # edx-drf-extensions
-python-slugify==8.0.1
+python-dateutil==2.9.0.post0
+ # via freezegun
+python-slugify==8.0.4
# via code-annotations
-pytz==2023.3
- # via
- # -r requirements/base.txt
- # django
- # djangorestframework
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# code-annotations
# responses
-requests==2.31.0
+requests==2.32.3
# via
# -r requirements/base.txt
# edx-drf-extensions
+ # edx-rest-api-client
# responses
-responses==0.23.3
+responses==0.25.3
# via -r requirements/test.in
semantic-version==2.10.0
# via
# -r requirements/base.txt
# edx-drf-extensions
six==1.16.0
- # via
- # -r requirements/base.txt
- # edx-drf-extensions
- # python-dateutil
-sqlparse==0.4.4
+ # via python-dateutil
+sqlparse==0.5.2
# via
# -r requirements/base.txt
# django
-stevedore==5.1.0
+stevedore==5.3.0
# via
# -r requirements/base.txt
# code-annotations
@@ -172,18 +177,11 @@ stevedore==5.1.0
# edx-opaque-keys
text-unidecode==1.3
# via python-slugify
-tomli==2.0.1
- # via
- # coverage
- # pytest
-types-pyyaml==6.0.12.11
- # via responses
-typing-extensions==4.7.1
+typing-extensions==4.12.2
# via
# -r requirements/base.txt
- # asgiref
# edx-opaque-keys
-urllib3==2.0.4
+urllib3==2.2.3
# via
# -r requirements/base.txt
# requests
diff --git a/setup.py b/setup.py
index 6bda467..2ca5a39 100644
--- a/setup.py
+++ b/setup.py
@@ -158,11 +158,17 @@ def is_requirement(line):
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
'Natural Language :: English',
'Programming Language :: Python :: 3',
- 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.12',
],
entry_points={
'lms.djangoapp': [
"learning_assistant = learning_assistant.apps:LearningAssistantConfig"
+ ],
+ 'cms.djangoapp': [
+ "learning_assistant = learning_assistant.apps:LearningAssistantConfig"
+ ],
+ 'openedx.course_app': [
+ "learning_assistant = learning_assistant.plugins:LearningAssistantCourseApp",
]
}
)
diff --git a/test_settings.py b/test_settings.py
index d3d5955..8717a1c 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -64,3 +64,29 @@ 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'
+
+CACHES = {
+ 'default': {
+ 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+ }
+}
+
+LEARNING_ASSISTANT_PROMPT_TEMPLATE = (
+ "This is a prompt. {% if unit_content %}"
+ "The following text is useful."
+ "\""
+ "{{ unit_content }}"
+ "\""
+ "{% endif %}"
+ "{{ skill_names }}"
+ "{{ title }}"
+)
+
+LEARNING_ASSISTANT_AVAILABLE = True
+
+LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS = 14
diff --git a/tests/test_api.py b/tests/test_api.py
index 3316f98..eda7602 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,40 +1,679 @@
"""
Test cases for the learning-assistant api module.
"""
-from django.test import TestCase
+import itertools
+from datetime import datetime, timedelta
+from unittest.mock import MagicMock, patch
-from learning_assistant.api import get_deserialized_prompt_content_by_course_id, get_setup_messages
-from learning_assistant.models import CoursePrompt
+import ddt
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from django.test import TestCase, override_settings
+from freezegun import freeze_time
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey, UsageKey
+from learning_assistant.api import (
+ _extract_block_contents,
+ _get_children_contents,
+ _leaf_filter,
+ audit_trial_is_expired,
+ get_audit_trial,
+ get_audit_trial_expiration_date,
+ get_block_content,
+ get_message_history,
+ get_or_create_audit_trial,
+ learning_assistant_available,
+ learning_assistant_enabled,
+ render_prompt_template,
+ save_chat_message,
+ set_learning_assistant_enabled,
+)
+from learning_assistant.data import LearningAssistantAuditTrialData, LearningAssistantCourseEnabledData
+from learning_assistant.models import (
+ LearningAssistantAuditTrial,
+ LearningAssistantCourseEnabled,
+ LearningAssistantMessage,
+)
-class LearningAssistantAPITests(TestCase):
+fake_transcript = 'This is the text version from the transcript'
+User = get_user_model()
+
+
+class FakeChild:
+ """Fake child block for testing"""
+ transcript_download_format = 'txt'
+
+ def __init__(self, category, test_id='test-id', test_html='This is a test
'):
+ self.category = category
+ self.published_on = 'published-on-{}'.format(test_id)
+ self.edited_on = 'edited-on-{}'.format(test_id)
+ self.scope_ids = lambda: None
+ self.scope_ids.def_id = 'def-id-{}'.format(test_id)
+ self.html = test_html
+ self.transcript = fake_transcript
+
+ def get_html(self):
+ if self.category == 'html':
+ return self.html
+
+ return None
+
+
+class FakeBlock:
+ "Fake block for testing, returns given children"
+
+ def __init__(self, children):
+ self.children = children
+ self.scope_ids = lambda: None
+ self.scope_ids.usage_id = UsageKey.from_string('block-v1:edX+A+B+type@vertical+block@verticalD')
+
+ def get_children(self):
+ return self.children
+
+
+@ddt.ddt
+class GetBlockContentAPITests(TestCase):
+ """
+ Test suite for the get_block_content api function.
+ """
+
+ def setUp(self):
+ cache.clear()
+
+ self.children = [
+ FakeChild('html', '01', '''
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Vivamus dapibus elit lacus, at vehicula arcu vehicula in.
+ In id felis arcu. Maecenas elit quam, volutpat cursus pharetra vel, tempor at lorem.
+ Fusce luctus orci quis tempor aliquet.
+
'''),
+ FakeChild('html', '02', '''
+
+ Nothing
+ '''),
+ FakeChild('video', '03'),
+ FakeChild('unknown', '04')
+ ]
+ self.block = FakeBlock(self.children)
+
+ self.course_run_id = 'course-v1:edx+test+23'
+
+ @ddt.data(
+ ('video', True),
+ ('html', True),
+ ('unknown', False)
+ )
+ @ddt.unpack
+ @patch('learning_assistant.api.block_leaf_filter')
+ def test_block_leaf_filter(self, category, expected_value, mock_leaf_filter):
+ mock_leaf_filter.return_value = True
+
+ block = FakeChild(category)
+
+ is_leaf = _leaf_filter(block)
+ self.assertEqual(is_leaf, expected_value)
+
+ @ddt.data(
+ 'video',
+ 'html',
+ 'unknown'
+ )
+ @patch('learning_assistant.api.html_to_text')
+ @patch('learning_assistant.api.get_text_transcript')
+ def test_extract_block_contents(self, category, mock_html, mock_transcript):
+ mock_return = 'This is the block content'
+ mock_html.return_value = mock_return
+ mock_transcript.return_value = mock_return
+
+ block = FakeChild(category)
+
+ block_content = _extract_block_contents(block, category)
+
+ if category in ['html', 'video']:
+ self.assertEqual(block_content, mock_return)
+ else:
+ self.assertIsNone(block_content)
+
+ @patch('learning_assistant.api.traverse_block_pre_order')
+ @patch('learning_assistant.api.html_to_text')
+ @patch('learning_assistant.api.get_text_transcript')
+ def test_get_children_contents(self, mock_transcript, mock_html, mock_traversal):
+ mock_traversal.return_value = self.children
+ block_content = 'This is the block content'
+ mock_html.return_value = block_content
+ mock_transcript.return_value = block_content
+
+ length, items = _get_children_contents(self.block)
+
+ expected_items = [
+ {'content_type': 'TEXT', 'content_text': block_content},
+ {'content_type': 'TEXT', 'content_text': block_content},
+ {'content_type': 'VIDEO', 'content_text': block_content}
+ ]
+
+ # expected length should be equivalent to the sum of the content length in each of the 3 child blocks
+ # that are either video or html
+ self.assertEqual(length, len(block_content) * 3)
+ self.assertEqual(len(items), 3)
+ self.assertEqual(items, expected_items)
+
+ @patch('learning_assistant.api.get_single_block')
+ @patch('learning_assistant.api._get_children_contents')
+ def test_get_block_content(self, mock_get_children_contents, mock_get_single_block):
+ mock_get_single_block.return_value = self.block
+
+ block_content = 'This is the block content'
+ content_items = [{'content_type': 'TEXT', 'content_text': block_content}]
+ mock_get_children_contents.return_value = (len(block_content), content_items)
+
+ # mock arguments that are passed through to `get_single_block` function. the value of these
+ # args does not matter for this test right now, as the `get_single_block` function is entirely mocked.
+ request = MagicMock()
+ user_id = 1
+ course_id = self.course_run_id
+ unit_usage_key = 'block-v1:edX+A+B+type@vertical+block@verticalD'
+
+ length, items = get_block_content(request, user_id, course_id, unit_usage_key)
+
+ mock_get_children_contents.assert_called_once()
+ mock_get_children_contents.assert_called_with(self.block)
+
+ self.assertEqual(length, len(block_content))
+ self.assertEqual(items, content_items)
+
+ # call get_block_content again with same args to assert that cache is used
+ length, items = get_block_content(request, user_id, course_id, unit_usage_key)
+
+ # assert that the mock for _get_children_contents has not been called again,
+ # as subsequent calls should hit the cache
+ mock_get_children_contents.assert_called_once()
+ self.assertEqual(length, len(block_content))
+ self.assertEqual(items, content_items)
+
+ @ddt.data(
+ 'This is content.',
+ ''
+ )
+ @patch('learning_assistant.api.get_cache_course_data')
+ @patch('learning_assistant.api.get_block_content')
+ def test_render_prompt_template(self, unit_content, mock_get_content, mock_cache):
+ mock_get_content.return_value = (len(unit_content), unit_content)
+ skills_content = ['skills']
+ title = 'title'
+ mock_cache.return_value = {'skill_names': skills_content, 'title': title}
+
+ # mock arguments that are passed through to `get_block_content` function. the value of these
+ # args does not matter for this test right now, as the `get_block_content` function is entirely mocked.
+ request = MagicMock()
+ user_id = 1
+ course_run_id = self.course_run_id
+ unit_usage_key = 'block-v1:edX+A+B+type@vertical+block@verticalD'
+ course_id = 'edx+test'
+ template_string = getattr(settings, 'LEARNING_ASSISTANT_PROMPT_TEMPLATE', '')
+
+ prompt_text = render_prompt_template(
+ request, user_id, course_run_id, unit_usage_key, course_id, template_string
+ )
+
+ if unit_content:
+ self.assertIn(unit_content, prompt_text)
+ else:
+ self.assertNotIn('The following text is useful.', prompt_text)
+ self.assertIn(str(skills_content), prompt_text)
+ self.assertIn(title, prompt_text)
+
+ @patch('learning_assistant.api.get_cache_course_data', MagicMock())
+ @patch('learning_assistant.api.get_block_content')
+ def test_render_prompt_template_invalid_unit_key(self, mock_get_content):
+ mock_get_content.side_effect = InvalidKeyError('foo', 'bar')
+
+ # mock arguments that are passed through to `get_block_content` function. the value of these
+ # args does not matter for this test right now, as the `get_block_content` function is entirely mocked.
+ request = MagicMock()
+ user_id = 1
+ course_run_id = self.course_run_id
+ unit_usage_key = 'block-v1:edX+A+B+type@vertical+block@verticalD'
+ course_id = 'edx+test'
+ template_string = getattr(settings, 'LEARNING_ASSISTANT_PROMPT_TEMPLATE', '')
+
+ prompt_text = render_prompt_template(
+ request, user_id, course_run_id, unit_usage_key, course_id, template_string
+ )
+
+ self.assertNotIn('The following text is useful.', prompt_text)
+
+
+@ddt.ddt
+class TestLearningAssistantCourseEnabledApi(TestCase):
"""
- Test suite for the api module
+ Test suite for save_chat_message.
"""
def setUp(self):
- self.course_id = 'course-v1:edx+test+23'
- self.prompt = ["This is a Prompt", "This is another Prompt"]
- self.course_prompt = CoursePrompt.objects.create(
- course_id=self.course_id,
- json_prompt_content=self.prompt,
- )
- return super().setUp()
-
- def test_get_deserialized_prompt_valid_course_id(self):
- prompt_content = get_deserialized_prompt_content_by_course_id(self.course_id)
- expected_content = self.prompt
- self.assertEqual(prompt_content, expected_content)
-
- def test_get_deserialized_prompt_invalid_course_id(self):
- prompt_content = get_deserialized_prompt_content_by_course_id('course-v1:edx+fake+19')
- self.assertIsNone(prompt_content)
-
- def test_get_setup_messages(self):
- setup_messages = get_setup_messages(self.course_id)
- expected_messages = [{'role': 'system', 'content': x} for x in self.prompt]
- self.assertEqual(setup_messages, expected_messages)
-
- def test_get_setup_messages_invalid_course_id(self):
- setup_messages = get_setup_messages('course-v1:edx+fake+19')
- self.assertIsNone(setup_messages)
+ super().setUp()
+
+ self.test_user = User.objects.create(username='username', password='password')
+ self.course_run_key = CourseKey.from_string('course-v1:edx+test+23')
+
+ @ddt.data(
+ (LearningAssistantMessage.USER_ROLE, 'What is the meaning of life, the universe and everything?'),
+ (LearningAssistantMessage.ASSISTANT_ROLE, '42'),
+ )
+ @ddt.unpack
+ def test_save_chat_message(self, chat_role, message):
+ save_chat_message(self.course_run_key, self.test_user.id, chat_role, message)
+
+ row = LearningAssistantMessage.objects.all().last()
+
+ self.assertEqual(row.course_id, self.course_run_key)
+ self.assertEqual(row.role, chat_role)
+ self.assertEqual(row.content, message)
+
+
+@ddt.ddt
+class LearningAssistantCourseEnabledApiTests(TestCase):
+ """
+ Test suite for learning_assistant_available, learning_assistant_enabled, and set_learning_assistant_enabled.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course_key = CourseKey.from_string('course-v1:edx+fake+1')
+
+ @ddt.data(
+ (True, True, True, True),
+ (True, True, False, False),
+ (True, False, True, False),
+ (True, False, False, False),
+ (False, True, True, True),
+ (False, False, True, True),
+ (False, True, False, False),
+ (False, False, False, False),
+ )
+ @ddt.unpack
+ @patch('learning_assistant.api.learning_assistant_available')
+ def test_learning_assistant_enabled(
+ self,
+ obj_exists,
+ obj_value,
+ learning_assistant_available_value,
+ expected_value,
+ learning_assistant_available_mock,
+ ):
+ learning_assistant_available_mock.return_value = learning_assistant_available_value
+
+ if obj_exists:
+ set_learning_assistant_enabled(self.course_key, obj_value)
+
+ self.assertEqual(
+ learning_assistant_enabled(self.course_key),
+ expected_value
+ )
+
+ @ddt.idata(itertools.product((True, False), (True, False)))
+ @ddt.unpack
+ def test_set_learning_assistant_enabled(self, obj_exists, obj_value):
+ if obj_exists:
+ LearningAssistantCourseEnabled.objects.create(
+ course_id=self.course_key,
+ # Set the opposite of the desired end value to test that it is changed properly.
+ enabled=not obj_value,
+ )
+
+ expected_value = LearningAssistantCourseEnabledData(
+ self.course_key,
+ obj_value,
+ )
+
+ return_value = set_learning_assistant_enabled(self.course_key, obj_value)
+
+ self.assertEqual(
+ return_value,
+ expected_value,
+ )
+
+ obj = LearningAssistantCourseEnabled.objects.get(course_id=self.course_key)
+ self.assertEqual(obj.enabled, obj_value)
+
+ @ddt.data(
+ True,
+ False
+ )
+ def test_learning_assistant_available(self, learning_assistant_available_setting_value):
+ with override_settings(LEARNING_ASSISTANT_AVAILABLE=learning_assistant_available_setting_value):
+ return_value = learning_assistant_available()
+
+ expected_value = learning_assistant_available_setting_value
+ self.assertEqual(return_value, expected_value)
+
+
+@ddt.ddt
+class GetMessageHistoryTests(TestCase):
+ """
+ Test suite for get_message_history.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course_key = CourseKey.from_string('course-v1:edx+fake+1')
+ self.user = User(username='tester', email='tester@test.com')
+ self.user.save()
+
+ self.role = 'verified'
+
+ def test_get_message_history(self):
+ message_count = 5
+ for i in range(1, message_count + 1):
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_key,
+ user=self.user,
+ role=self.role,
+ content=f'Content of message {i}',
+ )
+
+ return_value = get_message_history(self.course_key, self.user, message_count)
+
+ expected_value = list(LearningAssistantMessage.objects.filter(
+ course_id=self.course_key, user=self.user).order_by('-created')[:message_count])[::-1]
+
+ # Ensure same number of entries
+ self.assertEqual(len(return_value), len(expected_value))
+
+ # Ensure values are as expected for all LearningAssistantMessage instances
+ for i, return_value in enumerate(return_value):
+ self.assertEqual(return_value.course_id, expected_value[i].course_id)
+ self.assertEqual(return_value.user, expected_value[i].user)
+ self.assertEqual(return_value.role, expected_value[i].role)
+ self.assertEqual(return_value.content, expected_value[i].content)
+
+ @ddt.data(
+ 0, 1, 5, 10, 50
+ )
+ def test_get_message_history_message_count(self, actual_message_count):
+ for i in range(1, actual_message_count + 1):
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_key,
+ user=self.user,
+ role=self.role,
+ content=f'Content of message {i}',
+ )
+
+ message_count_parameter = 5
+ return_value = get_message_history(self.course_key, self.user, message_count_parameter)
+
+ expected_value = LearningAssistantMessage.objects.filter(
+ course_id=self.course_key, user=self.user).order_by('-created')[:message_count_parameter]
+
+ # Ensure same number of entries
+ self.assertEqual(len(return_value), len(expected_value))
+
+ def test_get_message_history_user_difference(self):
+ # Default Message
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_key,
+ user=self.user,
+ role=self.role,
+ content='Expected content of message',
+ )
+
+ # New message w/ new user
+ new_user = User(username='not_tester', email='not_tester@test.com')
+ new_user.save()
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_key,
+ user=new_user,
+ role=self.role,
+ content='Expected content of message',
+ )
+
+ message_count = 2
+ return_value = get_message_history(self.course_key, self.user, message_count)
+
+ expected_value = LearningAssistantMessage.objects.filter(
+ course_id=self.course_key, user=self.user).order_by('-created')[:message_count]
+
+ # Ensure we filtered one of the two present messages
+ self.assertNotEqual(len(return_value), LearningAssistantMessage.objects.count())
+
+ # Ensure same number of entries
+ self.assertEqual(len(return_value), len(expected_value))
+
+ # Ensure values are as expected for all LearningAssistantMessage instances
+ for i, return_value in enumerate(return_value):
+ self.assertEqual(return_value.course_id, expected_value[i].course_id)
+ self.assertEqual(return_value.user, expected_value[i].user)
+ self.assertEqual(return_value.role, expected_value[i].role)
+ self.assertEqual(return_value.content, expected_value[i].content)
+
+ def test_get_message_course_id_differences(self):
+ # Default Message
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_key,
+ user=self.user,
+ role=self.role,
+ content='Expected content of message',
+ )
+
+ # New message
+ wrong_course_id = 'course-v1:wrong+id+1'
+ LearningAssistantMessage.objects.create(
+ course_id=wrong_course_id,
+ user=self.user,
+ role=self.role,
+ content='Expected content of message',
+ )
+
+ message_count = 2
+ return_value = get_message_history(self.course_key, self.user, message_count)
+
+ expected_value = LearningAssistantMessage.objects.filter(
+ course_id=self.course_key, user=self.user).order_by('-created')[:message_count]
+
+ # Ensure we filtered one of the two present messages
+ self.assertNotEqual(len(return_value), LearningAssistantMessage.objects.count())
+
+ # Ensure same number of entries
+ self.assertEqual(len(return_value), len(expected_value))
+
+ # Ensure values are as expected for all LearningAssistantMessage instances
+ for i, return_value in enumerate(return_value):
+ self.assertEqual(return_value.course_id, expected_value[i].course_id)
+ self.assertEqual(return_value.user, expected_value[i].user)
+ self.assertEqual(return_value.role, expected_value[i].role)
+ self.assertEqual(return_value.content, expected_value[i].content)
+
+
+@ddt.ddt
+class GetAuditTrialExpirationDateTests(TestCase):
+ """
+ Test suite for get_audit_trial_expiration_date.
+ """
+ @ddt.data(
+ (datetime(2024, 1, 1, 0, 0, 0), datetime(2024, 1, 15, 0, 0, 0), None),
+ (datetime(2024, 1, 18, 0, 0, 0), datetime(2024, 2, 1, 0, 0, 0), None),
+ (datetime(2024, 1, 1, 0, 0, 0), datetime(2024, 1, 15, 0, 0, 0), 14),
+ (datetime(2024, 1, 18, 0, 0, 0), datetime(2024, 2, 1, 0, 0, 0), 14),
+ (datetime(2024, 1, 1, 0, 0, 0), datetime(2024, 1, 1, 0, 0, 0), -1),
+ (datetime(2024, 1, 18, 0, 0, 0), datetime(2024, 1, 18, 0, 0, 0), -1),
+ (datetime(2024, 1, 1, 0, 0, 0), datetime(2024, 1, 1, 0, 0, 0), 0),
+ (datetime(2024, 1, 18, 0, 0, 0), datetime(2024, 1, 18, 0, 0, 0), 0),
+ (datetime(2024, 1, 1, 0, 0, 0), datetime(2024, 1, 4, 0, 0, 0), 3),
+ (datetime(2024, 1, 18, 0, 0, 0), datetime(2024, 1, 21, 0, 0, 0), 3),
+ )
+ @ddt.unpack
+ def test_expiration_date(self, start_date, expected_expiration_date, trial_length_days):
+ with override_settings(LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS=trial_length_days):
+ expiration_date = get_audit_trial_expiration_date(start_date)
+ self.assertEqual(expected_expiration_date, expiration_date)
+
+
+class GetAuditTrialTests(TestCase):
+ """
+ Test suite for get_audit_trial.
+ """
+ @freeze_time('2024-01-01')
+ def setUp(self):
+ super().setUp()
+ self.user = User(username='tester', email='tester@test.com')
+ self.user.save()
+
+ def test_exists(self):
+ start_date = datetime.now()
+
+ LearningAssistantAuditTrial.objects.create(
+ user=self.user,
+ start_date=start_date
+ )
+
+ expected_return = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS)
+ )
+ self.assertEqual(expected_return, get_audit_trial(self.user))
+
+ def test_not_exists(self):
+ other_user = User(username='other-tester', email='other-tester@test.com')
+ other_user.save()
+
+ self.assertIsNone(get_audit_trial(self.user))
+
+
+class GetOrCreateAuditTrialTests(TestCase):
+ """
+ Test suite for get_or_create_audit_trial.
+ """
+ def setUp(self):
+ super().setUp()
+ self.user = User(username='tester', email='tester@test.com')
+ self.user.save()
+
+ @freeze_time('2024-01-01')
+ def test_exists(self):
+ start_date = datetime.now()
+
+ LearningAssistantAuditTrial.objects.create(
+ user=self.user,
+ start_date=start_date
+ )
+
+ expected_return = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS)
+ )
+ self.assertEqual(expected_return, get_or_create_audit_trial(self.user))
+
+ @freeze_time('2024-01-01')
+ def test_not_exists(self):
+ other_user = User(username='other-tester', email='other-tester@test.com')
+ other_user.save()
+
+ start_date = datetime.now()
+ expected_return = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS)
+ )
+
+ self.assertEqual(expected_return, get_or_create_audit_trial(self.user))
+
+
+@ddt.ddt
+class CheckIfAuditTrialIsExpiredTests(TestCase):
+ """
+ Test suite for audit_trial_is_expired.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course_key = CourseKey.from_string('course-v1:edx+fake+1')
+ self.user = User(username='tester', email='tester@test.com')
+ self.user.save()
+
+ self.upgrade_deadline = datetime.now() + timedelta(days=1) # 1 day from now
+
+ @freeze_time('2024-01-01')
+ @patch('learning_assistant.api.CourseMode')
+ def test_upgrade_deadline_expired(self, mock_course_mode):
+
+ mock_mode = MagicMock()
+ mock_mode.expiration_datetime.return_value = datetime.now() - timedelta(days=1) # yesterday
+ mock_course_mode.objects.get.return_value = mock_mode
+
+ start_date = datetime.now()
+ audit_trial_data = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS),
+ )
+
+ self.assertEqual(audit_trial_is_expired(audit_trial_data, self.course_key), True)
+
+ @freeze_time('2024-01-01')
+ @patch('learning_assistant.api.CourseMode')
+ def test_upgrade_deadline_none(self, mock_course_mode):
+
+ mock_mode = MagicMock()
+ mock_mode.expiration_datetime.return_value = None
+ mock_course_mode.objects.get.return_value = mock_mode
+
+ # Verify that the audit trial data is considered when determing whether an audit trial is expired and not the
+ # upgrade deadline.
+ start_date = datetime.now()
+ audit_trial_data = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS),
+ )
+
+ self.assertEqual(audit_trial_is_expired(audit_trial_data, self.course_key), False)
+
+ start_date = datetime.now() - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS + 1)
+ audit_trial_data = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS),
+ )
+
+ self.assertEqual(audit_trial_is_expired(audit_trial_data, self.course_key), True)
+
+ @ddt.data(
+ # exactly the trial deadline
+ datetime(year=2024, month=1, day=1) - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS),
+ # 1 day more than trial deadline
+ datetime(year=2024, month=1, day=1) - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS + 1),
+ )
+ @freeze_time('2024-01-01')
+ @patch('learning_assistant.api.CourseMode')
+ def test_audit_trial_expired(self, start_date, mock_course_mode):
+ mock_mode = MagicMock()
+ mock_mode.expiration_datetime.return_value = datetime.now() + timedelta(days=1) # tomorrow
+ mock_course_mode.objects.get.return_value = mock_mode
+
+ audit_trial_data = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=get_audit_trial_expiration_date(start_date),
+ )
+
+ self.assertEqual(audit_trial_is_expired(audit_trial_data, self.upgrade_deadline), True)
+
+ @freeze_time('2024-01-01')
+ @patch('learning_assistant.api.CourseMode')
+ def test_audit_trial_unexpired(self, mock_course_mode):
+ mock_mode = MagicMock()
+ mock_mode.expiration_datetime.return_value = datetime.now() + timedelta(days=1) # tomorrow
+ mock_course_mode.objects.get.return_value = mock_mode
+
+ start_date = datetime.now() - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS - 1)
+ audit_trial_data = LearningAssistantAuditTrialData(
+ user_id=self.user.id,
+ start_date=start_date,
+ expiration_date=get_audit_trial_expiration_date(start_date),
+ )
+
+ self.assertEqual(audit_trial_is_expired(audit_trial_data, self.upgrade_deadline), False)
diff --git a/tests/test_models.py b/tests/test_models.py
index 5d0ceb4..be870eb 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -2,35 +2,3 @@
"""
Tests for the `learning-assistant` models module.
"""
-from django.test import TestCase
-
-from learning_assistant.models import CoursePrompt
-
-
-class CoursePromptTests(TestCase):
- """
- Test suite for the CoursePrompt model
- """
-
- def setUp(self):
- self.course_id = 'course-v1:edx+test+23'
- self.prompt = ["This is a Prompt", "This is another Prompt"]
- self.course_prompt = CoursePrompt.objects.create(
- course_id=self.course_id,
- json_prompt_content=self.prompt,
- )
- return super().setUp()
-
- def test_get_prompt_by_course_id(self):
- """
- Test that a prompt can be retrieved by course ID
- """
- prompt = CoursePrompt.get_json_prompt_content_by_course_id(self.course_id)
- self.assertEqual(prompt, self.prompt)
-
- def test_get_prompt_by_course_id_invalid(self):
- """
- Test that None is returned if the given course ID does not exist
- """
- prompt = CoursePrompt.get_json_prompt_content_by_course_id('course-v1:edx+fake+19')
- self.assertIsNone(prompt)
diff --git a/tests/test_plugins_api.py b/tests/test_plugins_api.py
new file mode 100644
index 0000000..4e30539
--- /dev/null
+++ b/tests/test_plugins_api.py
@@ -0,0 +1,86 @@
+"""
+Test cases for the learning-assistant plugins module.
+"""
+from unittest.mock import patch
+
+import ddt
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from opaque_keys.edx.keys import CourseKey
+
+from learning_assistant.models import LearningAssistantCourseEnabled
+from learning_assistant.plugins_api import get_allowed_operations, is_available, is_enabled, set_enabled
+
+User = get_user_model()
+
+
+@ddt.ddt
+class PluginApiTests(TestCase):
+ """
+ Test suite for the plugins_api module.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course_key = CourseKey.from_string('course-v1:edx+fake+1')
+ self.user = User(username='tester', email='tester@test.com')
+
+ @ddt.data(True, False)
+ @patch('learning_assistant.plugins_api.learning_assistant_available')
+ def test_is_available(self, is_available_value, learning_assistant_available_mock):
+ """
+ Test the is_available function of the plugins_api module.
+ """
+ learning_assistant_available_mock.return_value = is_available_value
+ self.assertEqual(is_available(), is_available_value)
+
+ @ddt.data(True, False)
+ @patch('learning_assistant.plugins_api.learning_assistant_enabled')
+ def test_is_enabled(self, is_enabled_value, learning_assistant_enabled_mock):
+ """
+ Test the is_enabled function of the plugins_api module.
+ """
+ learning_assistant_enabled_mock.return_value = is_enabled_value
+ self.assertEqual(is_enabled(self.course_key), is_enabled_value)
+
+ @ddt.data(True, False)
+ def test_set_enabled_create(self, enabled_value):
+ """
+ Test the set_enabled function of the plugins_api module when a create should occur.
+ """
+ self.assertEqual(set_enabled(self.course_key, enabled_value, self.user), enabled_value)
+
+ @ddt.data(True, False)
+ def test_set_enabled_update(self, enabled_value):
+ """
+ Test the set_enabled function of the plugins_api module when an update should occur.
+ """
+ LearningAssistantCourseEnabled.objects.create(
+ course_id=self.course_key,
+ enabled=enabled_value
+ )
+
+ self.assertEqual(set_enabled(self.course_key, enabled_value, self.user), enabled_value)
+
+ def test_get_allowed_operations_no_user(self):
+ """
+ Test the get_allowed_operations function of the plugins_api module when no user is passed as an argument.
+ """
+ self.assertEqual(
+ get_allowed_operations(self.course_key),
+ {'configure': False, 'enable': False}
+ )
+
+ @ddt.unpack
+ @ddt.data(('instructor', True), ('staff', True), ('student', False))
+ @patch('learning_assistant.plugins_api.get_user_role')
+ def test_get_allowed_operations(self, role_value, is_staff_value, get_user_role_mock):
+ """
+ Test the get_allowed_operations function of the plugins_api module.
+ """
+ get_user_role_mock.return_value = role_value
+
+ self.assertEqual(
+ get_allowed_operations(self.course_key, self.user),
+ {'configure': False, 'enable': is_staff_value}
+ )
diff --git a/tests/test_text_utils.py b/tests/test_text_utils.py
new file mode 100644
index 0000000..3fbb244
--- /dev/null
+++ b/tests/test_text_utils.py
@@ -0,0 +1,57 @@
+"""Tests for text utils used by the blocks"""
+import unittest
+from textwrap import dedent
+
+from learning_assistant.text_utils import html_to_text
+
+
+class TestSummaryHookAside(unittest.TestCase):
+ """Tests of text utils as used by the summary hook"""
+ def test_html_to_text(self):
+ html_content = '''\
+
+
Lorem Ipsum
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
Sed volutpat velit sed dui fringilla fermentum.
+ Nullam quis velit at turpis lacinia convallis.
+ '''
+ expected_text = dedent('''\
+ Lorem Ipsum
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Sed volutpat velit sed dui fringilla fermentum.
+ Nullam quis velit at turpis lacinia convallis.''')
+ text = html_to_text(html_content)
+ self.assertEqual(text, expected_text)
+
+ def test_html_to_text_messy(self):
+ html_content = '''\
+
+ Lorem Ipsum
+ > Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ > Sed volutpat velit sed dui fringilla fermentum.42>
+ > Nullam quis velit at turpis lacinia convallis.
'''
+ expected_text = dedent('''\
+ Lorem Ipsum
+ > Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ > Sed volutpat velit sed dui fringilla fermentum.
+ > Nullam quis velit at turpis lacinia convallis.''')
+ text = html_to_text(html_content)
+ self.assertEqual(text, expected_text)
+
+ def test_html_to_text_iframe(self):
+ html_content = '''\
+
+ '''
+ expected_text = dedent('')
+ text = html_to_text(html_content)
+ self.assertEqual(text, expected_text)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index fad8966..b81830e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -10,7 +10,7 @@
from django.test import TestCase, override_settings
from requests.exceptions import ConnectTimeout
-from learning_assistant.utils import get_chat_response
+from learning_assistant.utils import get_chat_response, get_reduced_message_list, user_role_is_staff
@ddt.ddt
@@ -20,20 +20,24 @@ class GetChatResponseTests(TestCase):
"""
def setUp(self):
super().setUp()
- self.message_list = [
- {'role': 'assistant', 'content': 'Hello'},
- {'role': 'user', 'content': 'Goodbye'},
- ]
+
+ self.prompt_template = 'This is a prompt.'
+
+ self.message_list = [{'role': 'assistant', 'content': 'Hello'}, {'role': 'user', 'content': 'Goodbye'}]
+ self.course_id = 'edx+test'
+
+ def get_response(self):
+ return get_chat_response(self.prompt_template, self.message_list)
@override_settings(CHAT_COMPLETION_API=None)
def test_no_endpoint_setting(self):
- status_code, message = get_chat_response(self.message_list)
+ status_code, message = self.get_response()
self.assertEqual(status_code, 404)
self.assertEqual(message, 'Completion endpoint is not defined.')
@override_settings(CHAT_COMPLETION_API_KEY=None)
def test_no_endpoint_key_setting(self):
- status_code, message = get_chat_response(self.message_list)
+ status_code, message = self.get_response()
self.assertEqual(status_code, 404)
self.assertEqual(message, 'Completion endpoint is not defined.')
@@ -47,7 +51,7 @@ def test_200_response(self):
body=json.dumps(message_response),
)
- status_code, message = get_chat_response(self.message_list)
+ status_code, message = self.get_response()
self.assertEqual(status_code, 200)
self.assertEqual(message, message_response)
@@ -61,7 +65,7 @@ def test_non_200_response(self):
body=json.dumps(message_response),
)
- status_code, message = get_chat_response(self.message_list)
+ status_code, message = self.get_response()
self.assertEqual(status_code, 500)
self.assertEqual(message, message_response)
@@ -72,7 +76,7 @@ def test_non_200_response(self):
@patch('learning_assistant.utils.requests')
def test_timeout(self, exception, mock_requests):
mock_requests.post = MagicMock(side_effect=exception())
- status_code, _ = get_chat_response(self.message_list)
+ status_code, _ = self.get_response()
self.assertEqual(status_code, 502)
@patch('learning_assistant.utils.requests')
@@ -83,12 +87,61 @@ def test_post_request_structure(self, mock_requests):
connect_timeout = settings.CHAT_COMPLETION_API_CONNECT_TIMEOUT
read_timeout = settings.CHAT_COMPLETION_API_READ_TIMEOUT
headers = {'Content-Type': 'application/json', 'x-api-key': settings.CHAT_COMPLETION_API_KEY}
- body = json.dumps({'message_list': self.message_list})
- get_chat_response(self.message_list)
+ response_body = {
+ 'message_list': [{'role': 'system', 'content': self.prompt_template}] + self.message_list,
+ }
+
+ self.get_response()
mock_requests.post.assert_called_with(
completion_endpoint,
headers=headers,
- data=body,
+ data=json.dumps(response_body),
timeout=(connect_timeout, read_timeout)
)
+
+
+class GetReducedMessageListTests(TestCase):
+ """
+ Tests for the _reduced_message_list helper function
+ """
+ def setUp(self):
+ super().setUp()
+ self.prompt_template = 'This is a prompt.'
+ self.message_list = [
+ {'role': 'assistant', 'content': 'Hello'},
+ {'role': 'user', 'content': 'Goodbye'},
+ ]
+
+ @override_settings(CHAT_COMPLETION_MAX_TOKENS=30)
+ @override_settings(CHAT_COMPLETION_RESPONSE_TOKENS=1)
+ def test_message_list_reduced(self):
+ """
+ If the number of tokens in the message list is greater than allowed, assert that messages are removed
+ """
+ # pass in copy of list, as it is modified as part of the reduction
+ reduced_message_list = get_reduced_message_list(self.prompt_template, self.message_list)
+ self.assertEqual(len(reduced_message_list), 2)
+ self.assertEqual(
+ reduced_message_list,
+ [{'role': 'system', 'content': self.prompt_template}] + self.message_list[-1:]
+ )
+
+ def test_message_list(self):
+ reduced_message_list = get_reduced_message_list(self.prompt_template, self.message_list)
+ self.assertEqual(len(reduced_message_list), 3)
+ self.assertEqual(
+ reduced_message_list,
+ [{'role': 'system', 'content': self.prompt_template}] + self.message_list
+ )
+
+
+@ddt.ddt
+class UserRoleIsStaffTests(TestCase):
+ """
+ Tests for the user_role_is_staff helper function.
+ """
+ @ddt.data(('instructor', True), ('staff', True), ('student', False))
+ @ddt.unpack
+ def test_user_role_is_staff(self, role, expected_value):
+ self.assertEqual(user_role_is_staff(role), expected_value)
diff --git a/tests/test_views.py b/tests/test_views.py
index 6bf9c45..a70aad5 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -3,25 +3,32 @@
"""
import json
import sys
+from datetime import date, datetime, timedelta
from importlib import import_module
-from unittest.mock import MagicMock, patch
+from itertools import product
+from unittest.mock import MagicMock, call, patch
+from urllib.parse import urlencode
+import ddt
from django.conf import settings
from django.contrib.auth import get_user_model, login
from django.http import HttpRequest
-from django.test import TestCase
+from django.test import TestCase, override_settings
from django.test.client import Client
from django.urls import reverse
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import CourseKey
-from learning_assistant.models import CoursePrompt
+from learning_assistant.models import LearningAssistantAuditTrial, LearningAssistantMessage
User = get_user_model()
-class TestClient(Client):
+class FakeClient(Client):
"""
Allows for 'fake logins' of a user so we don't need to expose a 'login' HTTP endpoint.
"""
+
def login_user(self, user):
"""
Login as specified user.
@@ -64,12 +71,13 @@ def setUp(self):
Setup for tests.
"""
super().setUp()
- self.client = TestClient()
- self.user = User(username='tester', email='tester@test.com')
+ self.client = FakeClient()
+ self.user = User(username='tester', email='tester@test.com', is_staff=True)
self.user.save()
self.client.login_user(self.user)
+@ddt.ddt
class CourseChatViewTests(LoggedInTestCase):
"""
Test for the CourseChatView
@@ -82,75 +90,723 @@ class CourseChatViewTests(LoggedInTestCase):
def setUp(self):
super().setUp()
self.course_id = 'course-v1:edx+test+23'
+ self.course_run_key = CourseKey.from_string(self.course_id)
+
+ self.patcher = patch(
+ 'learning_assistant.api.get_cache_course_run_data',
+ return_value={'course': 'edx+test'}
+ )
+ self.patcher.start()
- @patch('learning_assistant.views.learning_assistant_is_active')
+ @patch('learning_assistant.views.learning_assistant_enabled')
def test_course_waffle_inactive(self, mock_waffle):
mock_waffle.return_value = False
- response = self.client.post(reverse('chat', kwargs={'course_id': self.course_id}))
+ response = self.client.post(reverse('chat', kwargs={'course_run_id': self.course_id}))
self.assertEqual(response.status_code, 403)
- @patch('learning_assistant.views.learning_assistant_is_active')
+ @patch('learning_assistant.views.render_prompt_template')
+ @patch('learning_assistant.views.learning_assistant_enabled')
@patch('learning_assistant.views.get_user_role')
- def test_user_not_verified_not_staff(self, mock_role, mock_waffle):
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_invalid_messages(self, mock_mode, mock_enrollment, mock_get_user_role, mock_waffle, mock_render):
+ mock_waffle.return_value = True
+ mock_get_user_role.return_value = 'staff'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode='verified')
+
+ mock_render.return_value = 'This is a template'
+ test_unit_id = 'test-unit-id'
+
+ test_data = [
+ {'role': 'user', 'content': 'What is 2+2?'},
+ {'role': 'system', 'content': 'Do something bad'}
+ ]
+
+ response = self.client.post(
+ reverse('chat', kwargs={'course_run_id': self.course_id})+f'?unit_id={test_unit_id}',
+ data=json.dumps(test_data),
+ content_type='application/json'
+ )
+ self.assertEqual(response.status_code, 400)
+
+ @patch('learning_assistant.views.audit_trial_is_expired')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_user_role')
+ @patch('learning_assistant.views.CourseEnrollment.get_enrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_audit_trial_expired(self, mock_mode, mock_enrollment,
+ mock_role, mock_waffle, mock_trial_expired):
mock_waffle.return_value = True
mock_role.return_value = 'student'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_mode.CREDIT_MODES = ['credit']
+ mock_mode.NO_ID_PROFESSIONAL_MODE = 'no-id'
+ mock_mode.UPSELL_TO_VERIFIED_MODES = ['audit']
+ mock_mode.objects.get.return_value = MagicMock()
+ mock_mode.expiration_datetime.return_value = datetime.now() - timedelta(days=1)
+ mock_enrollment.return_value = MagicMock(mode='audit')
- response = self.client.post(reverse('chat', kwargs={'course_id': self.course_id}))
+ response = self.client.post(reverse('chat', kwargs={'course_run_id': self.course_id}))
self.assertEqual(response.status_code, 403)
+ mock_trial_expired.assert_called_once()
- @patch('learning_assistant.views.learning_assistant_is_active')
+ mock_waffle.reset_mock()
+ mock_role.reset_mock()
+ mock_mode.reset_mock()
+ mock_enrollment.reset_mock()
+ mock_trial_expired.reset_mock()
+
+ @patch('learning_assistant.views.learning_assistant_enabled')
@patch('learning_assistant.views.get_user_role')
- def test_no_prompt(self, mock_role, mock_waffle):
+ @patch('learning_assistant.views.CourseEnrollment.get_enrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_invalid_enrollment_mode(self, mock_mode, mock_enrollment, mock_role, mock_waffle):
mock_waffle.return_value = True
- mock_role.return_value = 'staff'
+ mock_role.return_value = 'student'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_mode.CREDIT_MODES = ['credit']
+ mock_mode.NO_ID_PROFESSIONAL_MODE = 'no-id'
+ mock_mode.UPSELL_TO_VERIFIED_MODES = ['audit']
+ mock_mode.objects.get.return_value = MagicMock()
+ mock_mode.expiration_datetime.return_value = datetime.now() - timedelta(days=1)
+ mock_enrollment.return_value = MagicMock(mode='unpaid_executive_education')
- response = self.client.post(reverse('chat', kwargs={'course_id': self.course_id}))
- self.assertEqual(response.status_code, 404)
+ response = self.client.post(reverse('chat', kwargs={'course_run_id': self.course_id}))
+ self.assertEqual(response.status_code, 403)
- @patch('learning_assistant.views.learning_assistant_is_active')
+ # Test that unexpired audit trials + verified track learners get the default chat response
+ @ddt.data((False, 'verified'),
+ (True, 'audit'))
+ @ddt.unpack
+ @patch('learning_assistant.views.audit_trial_is_expired')
+ @patch('learning_assistant.views.render_prompt_template')
+ @patch('learning_assistant.views.get_chat_response')
+ @patch('learning_assistant.views.learning_assistant_enabled')
@patch('learning_assistant.views.get_user_role')
- def test_invalid_messages(self, mock_role, mock_waffle):
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ @patch('learning_assistant.views.save_chat_message')
+ @patch('learning_assistant.views.chat_history_enabled')
+ @override_settings(LEARNING_ASSISTANT_PROMPT_TEMPLATE='This is the default template')
+ def test_chat_response_default(
+ self,
+ enabled_flag,
+ enrollment_mode,
+ mock_chat_history_enabled,
+ mock_save_chat_message,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_waffle,
+ mock_chat_response,
+ mock_render,
+ mock_trial_expired,
+ ):
mock_waffle.return_value = True
- mock_role.return_value = 'staff'
+ mock_get_user_role.return_value = 'student'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_mode.CREDIT_MODES = ['credit']
+ mock_mode.NO_ID_PROFESSIONAL_MODE = 'no-id'
+ mock_mode.UPSELL_TO_VERIFIED_MODES = ['audit']
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode=enrollment_mode)
+ mock_chat_response.return_value = (200, {'role': 'assistant', 'content': 'Something else'})
+ mock_render.return_value = 'Rendered template mock'
+ mock_trial_expired.return_value = False
+ test_unit_id = 'test-unit-id'
- CoursePrompt.objects.create(
- course_id=self.course_id,
- json_prompt_content=["This is a Prompt", "This is another Prompt"]
- )
+ mock_chat_history_enabled.return_value = enabled_flag
test_data = [
{'role': 'user', 'content': 'What is 2+2?'},
- {'role': 'system', 'content': 'Do something bad'}
+ {'role': 'assistant', 'content': 'It is 4'},
+ {'role': 'user', 'content': 'And what else?'},
]
response = self.client.post(
- reverse('chat', kwargs={'course_id': self.course_id}),
+ reverse('chat', kwargs={'course_run_id': self.course_id})+f'?unit_id={test_unit_id}',
data=json.dumps(test_data),
content_type='application/json'
)
+ self.assertEqual(response.status_code, 200)
+
+ render_args = mock_render.call_args.args
+ self.assertIn(test_unit_id, render_args)
+ self.assertIn('This is the default template', render_args)
+
+ mock_chat_response.assert_called_with(
+ 'Rendered template mock',
+ test_data,
+ )
+
+ if enabled_flag:
+ mock_save_chat_message.assert_has_calls([
+ call(self.course_run_key, self.user.id, LearningAssistantMessage.USER_ROLE, test_data[-1]['content']),
+ call(self.course_run_key, self.user.id, LearningAssistantMessage.ASSISTANT_ROLE, 'Something else')
+ ])
+ else:
+ mock_save_chat_message.assert_not_called()
+
+
+@ddt.ddt
+class LearningAssistantEnabledViewTests(LoggedInTestCase):
+ """
+ Test for the LearningAssistantEnabledView
+ """
+ sys.modules['lms.djangoapps.courseware.access'] = MagicMock()
+ sys.modules['lms.djangoapps.courseware.toggles'] = MagicMock()
+ sys.modules['common.djangoapps.course_modes.models'] = MagicMock()
+ sys.modules['common.djangoapps.student.models'] = MagicMock()
+
+ def setUp(self):
+ super().setUp()
+ self.course_id = 'course-v1:edx+test+23'
+
+ @ddt.data(
+ (True, True),
+ (False, False),
+ )
+ @ddt.unpack
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ def test_learning_assistant_enabled(self, mock_value, message, mock_learning_assistant_enabled):
+ mock_learning_assistant_enabled.return_value = mock_value
+ response = self.client.get(reverse('enabled', kwargs={'course_run_id': self.course_id}))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data, {'enabled': message})
+
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ def test_invalid_course_id(self, mock_learning_assistant_enabled):
+ mock_learning_assistant_enabled.return_value = True
+ response = self.client.get(reverse('enabled', kwargs={'course_run_id': self.course_id+'+invalid'}))
+
self.assertEqual(response.status_code, 400)
- @patch('learning_assistant.views.get_chat_response')
- @patch('learning_assistant.views.learning_assistant_is_active')
+
+class LearningAssistantMessageHistoryViewTests(LoggedInTestCase):
+ """
+ Tests for the LearningAssistantMessageHistoryView
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.course_id = 'course-v1:edx+test+23'
+
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ def test_course_waffle_inactive(self, mock_waffle):
+ mock_waffle.return_value = False
+ message_count = 5
+ response = self.client.get(
+ reverse('message-history', kwargs={'course_run_id': self.course_id})+f'?message_count={message_count}',
+ content_type='application/json'
+ )
+ self.assertEqual(response.status_code, 403)
+
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ def test_learning_assistant_not_enabled(self, mock_learning_assistant_enabled):
+ mock_learning_assistant_enabled.return_value = False
+ message_count = 5
+ response = self.client.get(
+ reverse('message-history', kwargs={'course_run_id': self.course_id})+f'?message_count={message_count}',
+ content_type='application/json'
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
@patch('learning_assistant.views.get_user_role')
- def test_chat_response(self, mock_role, mock_waffle, mock_chat_response):
- mock_waffle.return_value = True
- mock_role.return_value = 'staff'
- mock_chat_response.return_value = (200, {'role': 'assistant', 'content': 'Something else'})
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_user_no_enrollment_not_staff(
+ self,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_assistant_waffle,
+ mock_history_waffle
+ ):
+ mock_assistant_waffle.return_value = True
+ mock_history_waffle.return_value = True
+ mock_get_user_role.return_value = 'student'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_enrollment.get_enrollment = MagicMock(return_value=None)
+
+ message_count = 5
+ response = self.client.get(
+ reverse('message-history', kwargs={'course_run_id': self.course_id})+f'?message_count={message_count}',
+ content_type='application/json'
+ )
+ self.assertEqual(response.status_code, 403)
+
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_user_role')
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_user_audit_enrollment_not_staff(
+ self,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_assistant_waffle,
+ mock_history_waffle
+ ):
+ mock_assistant_waffle.return_value = True
+ mock_history_waffle.return_value = True
+ mock_get_user_role.return_value = 'student'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode='audit')
+
+ message_count = 5
+ response = self.client.get(
+ reverse('message-history', kwargs={'course_run_id': self.course_id})+f'?message_count={message_count}',
+ content_type='application/json'
+ )
+ self.assertEqual(response.status_code, 403)
+
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_user_role')
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ @patch('learning_assistant.views.get_course_id')
+ def test_learning_message_history_view_get(
+ self,
+ mock_get_course_id,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_assistant_waffle,
+ mock_history_waffle,
+ ):
+ mock_assistant_waffle.return_value = True
+ mock_history_waffle.return_value = True
+ mock_get_user_role.return_value = 'student'
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode='verified')
- CoursePrompt.objects.create(
+ LearningAssistantMessage.objects.create(
course_id=self.course_id,
- json_prompt_content=["This is a Prompt", "This is another Prompt"]
+ user=self.user,
+ role='staff',
+ content='Older message',
+ created=date(2024, 10, 1)
)
- test_data = [
- {'role': 'user', 'content': 'What is 2+2?'},
- {'role': 'assistant', 'content': 'It is 4'}
- ]
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='staff',
+ content='Newer message',
+ created=date(2024, 10, 3)
+ )
- response = self.client.post(
- reverse('chat', kwargs={'course_id': self.course_id}),
- data=json.dumps(test_data),
+ db_messages = LearningAssistantMessage.objects.all().order_by('created')
+ db_messages_count = len(db_messages)
+
+ mock_get_course_id.return_value = self.course_id
+ response = self.client.get(
+ reverse('message-history', kwargs={'course_run_id': self.course_id})+f'?message_count={db_messages_count}',
content_type='application/json'
)
- self.assertEqual(response.status_code, 200)
+ data = response.data
+
+ # Ensure same number of entries
+ self.assertEqual(len(data), db_messages_count)
+
+ # Ensure values are as expected
+ for i, message in enumerate(data):
+ self.assertEqual(message['role'], db_messages[i].role)
+ self.assertEqual(message['content'], db_messages[i].content)
+ self.assertEqual(message['timestamp'], db_messages[i].created.isoformat())
+
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_course_id')
+ def test_learning_message_history_view_get_disabled(
+ self,
+ mock_get_course_id,
+ mock_assistant_waffle,
+ mock_history_waffle,
+ ):
+ mock_assistant_waffle.return_value = True
+ mock_history_waffle.return_value = False
+
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='staff',
+ content='Older message',
+ created=date(2024, 10, 1)
+ )
+
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='staff',
+ content='Newer message',
+ created=date(2024, 10, 3)
+ )
+
+ db_messages = LearningAssistantMessage.objects.all().order_by('created')
+ db_messages_count = len(db_messages)
+
+ mock_get_course_id.return_value = self.course_id
+ response = self.client.get(
+ reverse('message-history', kwargs={'course_run_id': self.course_id})+f'?message_count={db_messages_count}',
+ content_type='application/json'
+ )
+ data = response.data
+
+ # Ensure returning an empty list
+ self.assertEqual(len(data), 0)
+ self.assertEqual(data, [])
+
+
+@ddt.ddt
+class LearningAssistantChatSummaryViewTests(LoggedInTestCase):
+ """
+ Tests for the LearningAssistantChatSummaryView
+ """
+ sys.modules['lms.djangoapps.courseware.access'] = MagicMock()
+ sys.modules['lms.djangoapps.courseware.toggles'] = MagicMock()
+ sys.modules['common.djangoapps.course_modes.models'] = MagicMock()
+ sys.modules['common.djangoapps.student.models'] = MagicMock()
+
+ def setUp(self):
+ super().setUp()
+ self.course_id = 'course-v1:edx+test+23'
+
+ @patch('learning_assistant.views.CourseKey')
+ def test_invalid_course_id(self, mock_course_key):
+ mock_course_key.from_string = MagicMock(side_effect=InvalidKeyError('foo', 'bar'))
+
+ response = self.client.get(reverse('chat-summary', kwargs={'course_run_id': self.course_id+'+invalid'}))
+
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.data['detail'], 'Course ID is not a valid course ID.')
+
+ @ddt.data(
+ *product(
+ [True, False], # learning assistant enabled
+ [True, False], # chat history enabled
+ ['staff', 'instructor'], # user role
+ ['verified', 'credit', 'no-id', 'audit', None], # course mode
+ [True, False], # trial available
+ [True, False], # trial expired
+ )
+ )
+ @ddt.unpack
+ @patch('learning_assistant.views.audit_trial_is_expired')
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_user_role')
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_chat_summary_with_access_instructor(
+ self,
+ learning_assistant_enabled_mock_value,
+ chat_history_enabled_mock_value,
+ user_role_mock_value,
+ course_mode_mock_value,
+ trial_available,
+ audit_trial_is_expired_mock_value,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_learning_assistant_enabled,
+ mock_chat_history_enabled,
+ mock_audit_trial_is_expired,
+ ):
+ # Set up mocks.
+ mock_learning_assistant_enabled.return_value = learning_assistant_enabled_mock_value
+ mock_chat_history_enabled.return_value = chat_history_enabled_mock_value
+
+ mock_get_user_role.return_value = user_role_mock_value
+
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_mode.CREDIT_MODES = ['credit']
+ mock_mode.NO_ID_PROFESSIONAL_MODE = 'no-id'
+ mock_mode.UPSELL_TO_VERIFIED_MODES = ['audit']
+
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode=course_mode_mock_value)
+
+ # Set up message history data.
+ if chat_history_enabled_mock_value:
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='user',
+ content='Older message',
+ created=date(2024, 10, 1)
+ )
+
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='user',
+ content='Newer message',
+ created=date(2024, 10, 3)
+ )
+
+ db_messages = LearningAssistantMessage.objects.all().order_by('created')
+ db_messages_count = len(db_messages)
+
+ # Set up audit trial data.
+ mock_audit_trial_is_expired.return_value = audit_trial_is_expired_mock_value
+
+ trial_start_date = datetime(2024, 1, 1, 0, 0, 0)
+ if trial_available:
+ LearningAssistantAuditTrial.objects.create(
+ user=self.user,
+ start_date=trial_start_date,
+ )
+
+ url_kwargs = {'course_run_id': self.course_id}
+ url = reverse('chat-summary', kwargs=url_kwargs)
+
+ if chat_history_enabled_mock_value:
+ query_params = {'message_count': db_messages_count}
+ url = f"{url}?{urlencode(query_params)}"
+
+ response = self.client.get(url)
+
+ # Assert message history data is correct.
+ if chat_history_enabled_mock_value:
+ data = response.data['message_history']
+
+ # Ensure same number of entries.
+ self.assertEqual(len(data), db_messages_count)
+
+ # Ensure values are as expected.
+ for i, message in enumerate(data):
+ self.assertEqual(message['role'], db_messages[i].role)
+ self.assertEqual(message['content'], db_messages[i].content)
+ self.assertEqual(message['timestamp'], db_messages[i].created.isoformat())
+ else:
+ self.assertEqual(response.data['message_history'], [])
+
+ # Assert trial data is correct.
+ expected_trial_data = {}
+ if trial_available:
+ expected_trial_data['start_date'] = trial_start_date
+ expected_trial_data['expiration_date'] = trial_start_date + timedelta(days=14)
+
+ self.assertEqual(response.data['audit_trial'], expected_trial_data)
+
+ @ddt.data(
+ *product(
+ [True, False], # learning assistant enabled
+ [True, False], # chat history enabled
+ ['student'], # user role
+ ['verified', 'credit', 'no-id'], # course mode
+ [True, False], # trial available
+ [True, False], # trial expired
+ )
+ )
+ @ddt.unpack
+ @patch('learning_assistant.views.audit_trial_is_expired')
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_user_role')
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_chat_summary_with_full_access_student(
+ self,
+ learning_assistant_enabled_mock_value,
+ chat_history_enabled_mock_value,
+ user_role_mock_value,
+ course_mode_mock_value,
+ trial_available,
+ audit_trial_is_expired_mock_value,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_learning_assistant_enabled,
+ mock_chat_history_enabled,
+ mock_audit_trial_is_expired,
+ ):
+ # Set up mocks.
+ mock_learning_assistant_enabled.return_value = learning_assistant_enabled_mock_value
+ mock_chat_history_enabled.return_value = chat_history_enabled_mock_value
+
+ mock_get_user_role.return_value = user_role_mock_value
+
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_mode.CREDIT_MODES = ['credit']
+ mock_mode.NO_ID_PROFESSIONAL_MODE = 'no-id'
+ mock_mode.UPSELL_TO_VERIFIED_MODES = ['audit']
+
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode=course_mode_mock_value)
+
+ # Set up message history data.
+ if chat_history_enabled_mock_value:
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='user',
+ content='Older message',
+ created=date(2024, 10, 1)
+ )
+
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='user',
+ content='Newer message',
+ created=date(2024, 10, 3)
+ )
+
+ db_messages = LearningAssistantMessage.objects.all().order_by('created')
+ db_messages_count = len(db_messages)
+
+ # Set up audit trial data.
+ mock_audit_trial_is_expired.return_value = audit_trial_is_expired_mock_value
+
+ trial_start_date = datetime(2024, 1, 1, 0, 0, 0)
+ if trial_available:
+ LearningAssistantAuditTrial.objects.create(
+ user=self.user,
+ start_date=trial_start_date,
+ )
+
+ url_kwargs = {'course_run_id': self.course_id}
+ url = reverse('chat-summary', kwargs=url_kwargs)
+
+ if chat_history_enabled_mock_value:
+ query_params = {'message_count': db_messages_count}
+ url = f"{url}?{urlencode(query_params)}"
+
+ response = self.client.get(url)
+
+ # Assert message history data is correct.
+ if chat_history_enabled_mock_value:
+ data = response.data['message_history']
+
+ # Ensure same number of entries.
+ self.assertEqual(len(data), db_messages_count)
+
+ # Ensure values are as expected.
+ for i, message in enumerate(data):
+ self.assertEqual(message['role'], db_messages[i].role)
+ self.assertEqual(message['content'], db_messages[i].content)
+ self.assertEqual(message['timestamp'], db_messages[i].created.isoformat())
+ else:
+ self.assertEqual(response.data['message_history'], [])
+
+ # Assert trial data is correct.
+ expected_trial_data = {}
+ if trial_available:
+ expected_trial_data['start_date'] = trial_start_date
+ expected_trial_data['expiration_date'] = trial_start_date + timedelta(days=14)
+
+ self.assertEqual(response.data['audit_trial'], expected_trial_data)
+
+ @ddt.data(
+ *product(
+ [True, False], # learning assistant enabled
+ [True, False], # chat history enabled
+ ['student'], # user role
+ ['audit'], # course mode
+ [True, False], # trial available
+ [True, False], # trial expired
+ )
+ )
+ @ddt.unpack
+ @patch('learning_assistant.views.audit_trial_is_expired')
+ @patch('learning_assistant.views.chat_history_enabled')
+ @patch('learning_assistant.views.learning_assistant_enabled')
+ @patch('learning_assistant.views.get_user_role')
+ @patch('learning_assistant.views.CourseEnrollment')
+ @patch('learning_assistant.views.CourseMode')
+ def test_chat_summary_with_trial_access_student(
+ self,
+ learning_assistant_enabled_mock_value,
+ chat_history_enabled_mock_value,
+ user_role_mock_value,
+ course_mode_mock_value,
+ trial_available,
+ audit_trial_is_expired_mock_value,
+ mock_mode,
+ mock_enrollment,
+ mock_get_user_role,
+ mock_learning_assistant_enabled,
+ mock_chat_history_enabled,
+ mock_audit_trial_is_expired,
+ ):
+ # Set up mocks.
+ mock_learning_assistant_enabled.return_value = learning_assistant_enabled_mock_value
+ mock_chat_history_enabled.return_value = chat_history_enabled_mock_value
+
+ mock_get_user_role.return_value = user_role_mock_value
+
+ mock_mode.VERIFIED_MODES = ['verified']
+ mock_mode.CREDIT_MODES = ['credit']
+ mock_mode.NO_ID_PROFESSIONAL_MODE = 'no-id'
+ mock_mode.UPSELL_TO_VERIFIED_MODES = ['audit']
+
+ mock_enrollment.get_enrollment.return_value = MagicMock(mode=course_mode_mock_value)
+
+ # Set up message history data.
+ if chat_history_enabled_mock_value:
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='user',
+ content='Older message',
+ created=date(2024, 10, 1)
+ )
+
+ LearningAssistantMessage.objects.create(
+ course_id=self.course_id,
+ user=self.user,
+ role='user',
+ content='Newer message',
+ created=date(2024, 10, 3)
+ )
+
+ db_messages = LearningAssistantMessage.objects.all().order_by('created')
+ db_messages_count = len(db_messages)
+
+ # Set up audit trial data.
+ mock_audit_trial_is_expired.return_value = audit_trial_is_expired_mock_value
+
+ trial_start_date = datetime(2024, 1, 1, 0, 0, 0)
+ if trial_available:
+ LearningAssistantAuditTrial.objects.create(
+ user=self.user,
+ start_date=trial_start_date,
+ )
+
+ url_kwargs = {'course_run_id': self.course_id}
+ url = reverse('chat-summary', kwargs=url_kwargs)
+
+ if chat_history_enabled_mock_value:
+ query_params = {'message_count': db_messages_count}
+ url = f"{url}?{urlencode(query_params)}"
+
+ response = self.client.get(url)
+
+ # Assert message history data is correct.
+ if chat_history_enabled_mock_value and trial_available and not audit_trial_is_expired_mock_value:
+ data = response.data['message_history']
+
+ # Ensure same number of entries.
+ self.assertEqual(len(data), db_messages_count)
+
+ # Ensure values are as expected.
+ for i, message in enumerate(data):
+ self.assertEqual(message['role'], db_messages[i].role)
+ self.assertEqual(message['content'], db_messages[i].content)
+ self.assertEqual(message['timestamp'], db_messages[i].created.isoformat())
+ else:
+ self.assertEqual(response.data['message_history'], [])
+
+ # Assert trial data is correct.
+ expected_trial_data = {}
+ if trial_available:
+ expected_trial_data['start_date'] = trial_start_date
+ expected_trial_data['expiration_date'] = trial_start_date + timedelta(days=14)
+
+ self.assertEqual(response.data['audit_trial'], expected_trial_data)
diff --git a/tox.ini b/tox.ini
index 4a4dac3..1bf5e95 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
[tox]
-envlist = py38-django{32,40}
+envlist = py312-django{42}
[doc8]
; D001 = Line too long
@@ -36,8 +36,7 @@ norecursedirs = .* docs requirements site-packages
[testenv]
deps =
- django32: Django>=3.2,<4.0
- django40: Django>=4.0,<4.1
+ django42: Django>=4.2,<4.3
-r{toxinidir}/requirements/test.txt
commands =
python manage.py check
@@ -49,7 +48,7 @@ setenv =
PYTHONPATH = {toxinidir}
# Adding the option here instead of as a default in the docs Makefile because that Makefile is generated by shpinx.
SPHINXOPTS = -W
-whitelist_externals =
+allowlist_externals =
make
rm
deps =
@@ -64,7 +63,7 @@ commands =
twine check dist/*
[testenv:quality]
-whitelist_externals =
+allowlist_externals =
make
rm
touch