Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ edx-extensions/
│ ├── ol_openedx_course_sync/ # Course synchronization
│ ├── ol_openedx_course_translations/ # Course translations
│ ├── ol_openedx_git_auto_export/ # Git export automation
│ ├── ol_openedx_lti_utilities/ # LTI Utilities
│ ├── ol_openedx_logging/ # Logging enhancements
│ ├── ol_openedx_otel_monitoring/ # OpenTelemetry monitoring
│ ├── ol_openedx_sentry/ # Sentry integration
Expand Down
10 changes: 10 additions & 0 deletions src/ol_openedx_lti_utilities/.coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[run]
branch = True
data_file = .coverage
source=ol_openedx_lti_utilities
omit =
test_settings.py
*/migrations/*
*admin.py
*/static/*
*/templates/*
12 changes: 12 additions & 0 deletions src/ol_openedx_lti_utilities/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Change Log
##########

..
All enhancements and patches to ol_openedx_lti_utilities will be documented
in this file. It adheres to the structure of https://keepachangelog.com/ ,
but in reStructuredText instead of Markdown (for ease of incorporation into
Sphinx documentation and the PyPI description).

This project adheres to Semantic Versioning (https://semver.org/).

.. There should always be an "Unreleased" section for changes pending release.
28 changes: 28 additions & 0 deletions src/ol_openedx_lti_utilities/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Copyright (C) 2023 MIT Open Learning

All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
4 changes: 4 additions & 0 deletions src/ol_openedx_lti_utilities/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
include CHANGELOG.rst
include LICENSE.txt
include README.rst
recursive-include ol_openedx_lti_utilities *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.py
39 changes: 39 additions & 0 deletions src/ol_openedx_lti_utilities/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
LTI Utilities Plugin
=============================

A django app plugin to add LTI related utilities in Open edX platform.


Installation
------------

For detailed installation instructions, please refer to the `plugin installation guide <../../docs#installation-guide>`_.

Installation required in:

* LMS

How To Use
----------

**API Request**

To manually call the API, Send a POST request to ``<LMS_BASE>/lti-user-fix/`` with a JSON body containing the following field:
- ``email``: The email address of the user whose LTI account needs to be fixed.

A sample request looks like below:

::

POST: http://local.openedx.io:8000/api/lti-user-fix/

Payload:
{
"email": "[email protected]"
}


API Response
------------

The successful response would be an indication that an LTI user in bad state was found and fixed. The response status code would be 200.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
ol_openedx_lti_utilities
"""
25 changes: 25 additions & 0 deletions src/ol_openedx_lti_utilities/ol_openedx_lti_utilities/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""
Ol Openedx LTI Utilities App Configuration
"""

from django.apps import AppConfig
from edx_django_utils.plugins import PluginURLs
from openedx.core.djangoapps.plugins.constants import ProjectType


class LTIUtilitiesConfig(AppConfig):
"""
Configuration class for Ol Openedx LTI Utilities
"""

name = "ol_openedx_lti_utilities"

plugin_app = {
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: "",
PluginURLs.REGEX: "^api/lti-user-fix/",
PluginURLs.RELATIVE_PATH: "urls",
}
},
}
15 changes: 15 additions & 0 deletions src/ol_openedx_lti_utilities/ol_openedx_lti_utilities/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
OL Open edX LTI Utilities URLs
"""

from django.urls import re_path

from ol_openedx_lti_utilities.views import LtiUserFixView

urlpatterns = [
re_path(
r"^",
LtiUserFixView.as_view(),
name="lti_user_fix",
),
]
120 changes: 120 additions & 0 deletions src/ol_openedx_lti_utilities/ol_openedx_lti_utilities/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Views for LTI Utilities operations.
"""

import logging

from django.db import transaction
from django.http import Http404, HttpResponseBadRequest
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import (
SessionAuthenticationAllowInactiveUser,
)
from lms.djangoapps.lti_provider.models import LtiUser
from openedx.core.djangoapps.user_api.accounts.utils import (
create_retirement_request_and_deactivate_account,
)
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from social_django.models import UserSocialAuth

log = logging.getLogger(__name__)


PLACEHOLDER_EMAIL_DOMAIN = "lti_example.com"


class LtiUserFixView(APIView):
"""
Fix the auth record of an LTI-created user.

POST /api/lti-user-fix/

Request payload:
{
"email": "<user_email>",
}

Responses:
- 200: Fixed successfully
- 400: Bad request or user does not need fixing
- 404: No matching LTI user found
"""

# Same authentication model as CourseModesMixin
authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)

# Same permission enforcement as CourseModesMixin
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)

# Only POST allowed
http_method_names = ["post"]

def post(self, request):
"""
Handle POST request to fix LTI user authentication record.

This endpoint fixes LTI-created users who have lti_user_id as usernames

Parameters
----------
request : Request
The HTTP request object containing email in the payload.

Returns
-------
Response
HTTP 200 on successful fix, HTTP 400 for bad requests or users that
don't need fixing, HTTP 404 if no matching LTI user is found

Raises
------
Http404
If no LTI user exists for the provided email address
"""
user_email = request.data.get("email")

if not user_email:
log.error("email is required")
return HttpResponseBadRequest("email is required")

# A user that is created by LTI will always have the same username as
# lti_user_id in LtiUser table.
with transaction.atomic():
lti_user = LtiUser.objects.filter(edx_user__email=user_email).first()
if not lti_user:
log.error("No user was found against the given email (%s)", user_email)
raise Http404
if lti_user.lti_user_id != lti_user.edx_user.username:
log.error(
"User with email (%s) does not appear to be an LTI-created user",
user_email,
)
return HttpResponseBadRequest(
"User with the given email does not appear to be an "
"LTI-created user."
)

user = lti_user.edx_user
user.email = user.email.split("@")[0] + "@" + PLACEHOLDER_EMAIL_DOMAIN
user.save()
# Remove social auth records for this user
UserSocialAuth.objects.filter(user=user).delete()
# Remove the old LTI mapping so that a new one gets created the next time
# users access edX via LTI
lti_user.delete()

# Send the user for retirement and deactivate the account
try:
create_retirement_request_and_deactivate_account(user)
except Exception as e: # noqa: BLE001
log.error("Error retiring and deactivating user: %s", e) # noqa: TRY400
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we get an error here, shouldn't we respond with an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I added it as an additional step and I explicitly didn't return error to MITx Online if retirement fails. That's because we would have already updated the email of the old user at this point so MITx Online registration can be retried. Let me know if we should link it to the error response we send to MITx Online.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, we can leave it as is and if we need more information passed back to mitxonline later we can circle back on it.


return Response(status=status.HTTP_200_OK)
37 changes: 37 additions & 0 deletions src/ol_openedx_lti_utilities/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[project]
name = "ol-openedx-lti-utilities"
version = "0.1.0"
description = "An Open edX plugin to add utilities for LTI operations"
authors = [
{name = "MIT Office of Digital Learning"}
]
license = "BSD-3-Clause"
readme = "README.rst"
requires-python = ">=3.11"
dependencies = [
"Django>=4.0",
"djangorestframework>=3.14.0",
"edx-django-utils>4.0.0",
"edx-drf-extensions>=10.0.0",
"edx-opaque-keys",
]

[project.entry-points."lms.djangoapp"]
ol_openedx_lti_utilities = "ol_openedx_lti_utilities.app:LTIUtilitiesConfig"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["ol_openedx_lti_utilities"]
include = [
"ol_openedx_lti_utilities/**/*.py",
]

[tool.hatch.build.targets.sdist]
include = [
"ol_openedx_lti_utilities/**/*",
"README.rst",
"pyproject.toml",
]
41 changes: 41 additions & 0 deletions src/ol_openedx_lti_utilities/setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[isort]
include_trailing_comma = True
indent = ' '
line_length = 120
multi_line_output = 3
skip=
migrations

[wheel]
universal = 1

[tool:pytest]
pep8maxlinelength = 119
DJANGO_SETTINGS_MODULE = lms.envs.test
addopts = --nomigrations --reuse-db --durations=20
# Enable default handling for all warnings, including those that are ignored by default;
# but hide rate-limit warnings (because we deliberately don't throttle test user logins)
# and field_data deprecation warnings (because fixing them requires a major low-priority refactoring)
filterwarnings =
default
ignore::xblock.exceptions.FieldDataDeprecationWarning
ignore::pytest.PytestConfigWarning
ignore:No request passed to the backend, unable to rate-limit:UserWarning
ignore:Flags not at the start of the expression:DeprecationWarning
ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc':DeprecationWarning
ignore:invalid escape sequence:DeprecationWarning
ignore:`formatargspec` is deprecated since Python 3.5:DeprecationWarning
ignore:the imp module is deprecated in favour of importlib:DeprecationWarning
ignore:"is" with a literal:SyntaxWarning
ignore:defusedxml.lxml is no longer supported:DeprecationWarning
ignore: `np.int` is a deprecated alias for the builtin `int`.:DeprecationWarning
ignore: `np.float` is a deprecated alias for the builtin `float`.:DeprecationWarning
ignore: `np.complex` is a deprecated alias for the builtin `complex`.:DeprecationWarning
ignore: 'etree' is deprecated. Use 'xml.etree.ElementTree' instead.:DeprecationWarning
ignore: defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead.:DeprecationWarning


junit_family = xunit2
norecursedirs = .* *.egg build conf dist node_modules test_root cms/envs lms/envs
python_classes =
python_files = tests.py test_*.py tests_*.py *_tests.py __init__.py
Empty file.
41 changes: 41 additions & 0 deletions src/ol_openedx_lti_utilities/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Pytest config"""

import json
import logging
from pathlib import Path

import pytest

BASE_DIR = Path(__file__).parent.absolute()


def pytest_addoption(parser):
"""Pytest hook that adds command line options"""
parser.addoption(
"--disable-logging",
action="store_true",
default=False,
help="Disable all logging during test run",
)
parser.addoption(
"--error-log-only",
action="store_true",
default=False,
help="Disable all logging output below 'error' level during test run",
)


def pytest_configure(config):
"""Pytest hook that runs after command line options have been parsed"""
if config.getoption("--disable-logging"):
logging.disable(logging.CRITICAL)
elif config.getoption("--error-log-only"):
logging.disable(logging.WARNING)


@pytest.fixture
def example_event(request):
"""An example real event captured previously""" # noqa: D401
with Path.open(BASE_DIR / ".." / "test_data" / "example_event.json") as f:
request.cls.example_event = json.load(f)
yield
Loading