diff --git a/src/edx_username_changer/BUILD b/src/edx_username_changer/BUILD new file mode 100644 index 00000000..b0b8ec28 --- /dev/null +++ b/src/edx_username_changer/BUILD @@ -0,0 +1,28 @@ +python_sources( + name="edx_username_changer_source", +) + +python_distribution( + name="edx_username_changer_package", + dependencies=[ + ":edx_username_changer_source", + ], + provides=setup_py( + name="edx-username-changer", + version="0.3.2", + description="An edX plugin to change username of edx accounts through admin panel", + license="BSD-3-Clause", + author="MIT Office of Digital Learning", + include_package_data=True, + zip_safe=False, + keywords="Python edx", + entry_points={ + "lms.djangoapp": [ + "edx_username_changer = edx_username_changer.apps:EdxUsernameChangerConfig", + ], + "cms.djangoapp": [ + "edx_username_changer = edx_username_changer.apps:EdxUsernameChangerConfig", + ], + }, + ), +) diff --git a/src/edx_username_changer/LICENCE b/src/edx_username_changer/LICENCE new file mode 100644 index 00000000..83284fb7 --- /dev/null +++ b/src/edx_username_changer/LICENCE @@ -0,0 +1,28 @@ +Copyright (C) 2022 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. diff --git a/src/edx_username_changer/README.rst b/src/edx_username_changer/README.rst new file mode 100644 index 00000000..9c00fc8d --- /dev/null +++ b/src/edx_username_changer/README.rst @@ -0,0 +1,56 @@ +Edx Username Changer +======================= + +A plugin to enable update usernames through admin panel in Open edX (and other apps that log into Open edX via OAuth). + +Version Compatibility +--------------------- + +It only supports koa and latest releases of Open edX. + +Installing The Plugin +--------------------- + +You can install this plugin into any Open edX instance by using any of the following methods: + +**Option 1: Install from PyPI** + +.. code-block:: + + # If running devstack in docker, first open a shell in LMS (make lms-shell) + + pip install edx-username-changer + + +**Option 2: Build the package locally and install it** + +Follow these steps in a terminal on your machine: + +1. Navigate to ``open-edx-plugins`` directory +2. Run ``./pants package ::``. This will create a "dist" directory inside "open-edx-plugins" directory with ".whl" & ".tar.gz" format packages for all plugins in the src directory +3. Move/copy any of the ".whl" or ".tar.gz" files for this plugin that were generated in the above step to the machine/container running Open edX (NOTE: If running devstack via Docker, you can use ``docker cp`` to copy these files into your LMS or CMS containers) +4. Run a shell in the machine/container running Open edX, and install this plugin using pip + + +``Note``: In some cases you might need to restart edx-platform after installing the plugin to reflect the changes. + +Configurations +-------------- +To configure this plugin, you need to do the following one step: + +1. Add/Enable a feature flag (ENABLE_EDX_USERNAME_CHANGER) into your environment variables (through ``private.py`` in LMS or CMS, depending upon where you are installing the plugin) + +.. code-block:: + FEATURES["ENABLE_EDX_USERNAME_CHANGER"] = True + +How to use +---------- +Its usage is as simple as changing the username of a user account through django's admin panel. Here are the steps (for clarity): + +1. Install edx-username-changer plugin. +2. Use an admin account to access django admin panel. +3. Go to Users model and select an account to change its username. +4. In the account editor page change the username field. +5. Hit Save (present at the bottom-right of page). + +The whole process can be done on lms or studio or on both of them. diff --git a/src/edx_username_changer/__init__.py b/src/edx_username_changer/__init__.py new file mode 100644 index 00000000..b4832711 --- /dev/null +++ b/src/edx_username_changer/__init__.py @@ -0,0 +1,3 @@ +# pylint: disable=missing-module-docstring + +default_app_config = "edx_username_changer.apps.EdxUsernameChangerConfig" diff --git a/src/edx_username_changer/admin.py b/src/edx_username_changer/admin.py new file mode 100644 index 00000000..9b0aebb8 --- /dev/null +++ b/src/edx_username_changer/admin.py @@ -0,0 +1,38 @@ +""" +Django admin pages for edx-username-changer plugin +""" + +import contextlib + +from common.djangoapps.student.admin import ( + UserAdmin as BaseUserAdmin, +) +from django.conf import settings +from django.contrib import admin +from django.contrib.admin.sites import NotRegistered +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserAdmin(BaseUserAdmin): + """ + Admin interface for the User model. + """ + + def get_readonly_fields(self, request, obj=None): + """ + Remove 'username' from the read-only fields + to make it editable through the admin panel + """ + readonly_fields = super().get_readonly_fields(request, obj) + if settings.FEATURES.get("ENABLE_EDX_USERNAME_CHANGER") and obj: + return tuple([name for name in readonly_fields if name != "username"]) + return readonly_fields + + +# We must first un-register the User model since it was registered by edX's core code. +with contextlib.suppress(NotRegistered): + admin.site.unregister(User) + +admin.site.register(User, UserAdmin) diff --git a/src/edx_username_changer/apps.py b/src/edx_username_changer/apps.py new file mode 100644 index 00000000..ecb3433f --- /dev/null +++ b/src/edx_username_changer/apps.py @@ -0,0 +1,33 @@ +""" +App configuration for edx-username-changer plugin +""" + +from django.apps import AppConfig +from edx_django_utils.plugins.constants import PluginSignals +from openedx.core.djangoapps.plugins.constants import ( + ProjectType, +) + + +class EdxUsernameChangerConfig(AppConfig): + name = "edx_username_changer" + verbose_name = "Open edX Username Changer" + + plugin_app = { + PluginSignals.CONFIG: { + ProjectType.LMS: { + PluginSignals.RECEIVERS: [ + { + PluginSignals.RECEIVER_FUNC_NAME: "user_pre_save_callback", + PluginSignals.SIGNAL_PATH: "django.db.models.signals.pre_save", + PluginSignals.SENDER_PATH: "django.contrib.auth.models.User", + }, + { + PluginSignals.RECEIVER_FUNC_NAME: "user_post_save_callback", + PluginSignals.SIGNAL_PATH: "django.db.models.signals.post_save", + PluginSignals.SENDER_PATH: "django.contrib.auth.models.User", + }, + ], + }, + }, + } diff --git a/src/edx_username_changer/exceptions.py b/src/edx_username_changer/exceptions.py new file mode 100644 index 00000000..3bacc579 --- /dev/null +++ b/src/edx_username_changer/exceptions.py @@ -0,0 +1,18 @@ +""" +Exceptions for edx-username-changer plugin +""" + + +class UpdateFailedException(Exception): # noqa: N818 + """ + Exception to throw when there is an update failure in username + """ + + def __init__(self, url, new_username): + self.url = url + self.new_username = new_username + + def __str__(self): + return ( + f"Username update failed for username: {self.new_username}, url: {self.url}" + ) diff --git a/src/edx_username_changer/signals.py b/src/edx_username_changer/signals.py new file mode 100644 index 00000000..5cf1ee7d --- /dev/null +++ b/src/edx_username_changer/signals.py @@ -0,0 +1,42 @@ +""" +Signals and Signal Handlers for edx-username-changer plugin +""" + +from common.djangoapps.util.model_utils import ( # pylint: disable=import-error + get_changed_fields_dict, +) +from django.conf import settings +from django.db import transaction +from edx_username_changer.tasks import task_update_username_in_forum +from edx_username_changer.utils import update_user_social_auth_uid + + +def user_pre_save_callback(sender, **kwargs): + """ + Pre-save signal handler of User model to store changed fields to be used later + """ + if settings.FEATURES.get("ENABLE_EDX_USERNAME_CHANGER"): + user = kwargs["instance"] + fields_to_update = get_changed_fields_dict(user, sender) + if "username" in fields_to_update: + fields_to_update.update({"new_username": user.username}) + user._updated_fields = fields_to_update # noqa: SLF001 + + +def user_post_save_callback(sender, **kwargs): # noqa: ARG001 + """ + Post-save signal handler of User model to update username throughout the application + """ + if settings.FEATURES.get("ENABLE_EDX_USERNAME_CHANGER"): + user = kwargs["instance"] + if ( + hasattr(user, "_updated_fields") + and user._updated_fields # noqa: SLF001 + and {"username", "new_username"}.issubset(user._updated_fields) # noqa: SLF001 + ): + new_username = user._updated_fields["new_username"] # noqa: SLF001 + transaction.on_commit( + lambda: task_update_username_in_forum.delay(new_username) + ) + update_user_social_auth_uid(user._updated_fields["username"], new_username) # noqa: SLF001 + delattr(user, "_updated_fields") diff --git a/src/edx_username_changer/tasks.py b/src/edx_username_changer/tasks.py new file mode 100644 index 00000000..8cbcd59f --- /dev/null +++ b/src/edx_username_changer/tasks.py @@ -0,0 +1,42 @@ +""" +This file contains celery tasks related to edx_username_changer plugin. +""" + +from celery import shared_task +from django.contrib.auth import get_user_model +from edx_username_changer.utils import ( + get_authored_threads_and_comments, + get_enrolled_course_ids, + update_comment_user_username, + update_comment_username, + update_thread_username, +) +from openedx.core.djangoapps.django_comment_common.comment_client.user import ( + User as CommentUser, +) + +COMMENT_TYPE = "comment" +THREAD_TYPE = "thread" +User = get_user_model() + + +@shared_task() +def task_update_username_in_forum(username): + """ + Change username in Discussion-Forum service + """ + user = User.objects.get(username=username) + comment_user = CommentUser.from_django_user(user) + update_comment_user_username(comment_user, user.username) + enrolled_course_ids = get_enrolled_course_ids(user) + authored_items = get_authored_threads_and_comments( + comment_user, enrolled_course_ids + ) + + for authored_item in authored_items: + item_id = authored_item["id"] + item_type = str(authored_item.get("type")) + if item_type == THREAD_TYPE: + update_thread_username(item_id, user.username) + elif item_type == COMMENT_TYPE: + update_comment_username(item_id, user.username) diff --git a/src/edx_username_changer/utils.py b/src/edx_username_changer/utils.py new file mode 100644 index 00000000..0f2481f9 --- /dev/null +++ b/src/edx_username_changer/utils.py @@ -0,0 +1,128 @@ +""" +Utility methods for edx-username-changer plugin +""" + +from common.djangoapps.student.models import ( + CourseEnrollment, +) +from django.contrib.auth import get_user_model +from django.db import transaction +from edx_username_changer.exceptions import UpdateFailedException +from openedx.core.djangoapps.django_comment_common.comment_client.comment import ( + Comment, +) +from openedx.core.djangoapps.django_comment_common.comment_client.thread import ( + Thread, +) +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + perform_request as perform_forum_request, +) +from social_django.models import UserSocialAuth + +User = get_user_model() + + +def update_user_social_auth_uid(old_username, new_username): + """ + Change uid in django-social-auth for OAuth based user accounts + iff uid is based on username otherwise it doesn't make any effect + """ + with transaction.atomic(): + UserSocialAuth.objects.filter(uid=old_username).update(uid=new_username) + + +def get_enrolled_course_ids(user): + """ + Return course ids of all the active enrollments of the provided user + """ + return [ + str(enrollment.course_id) + for enrollment in CourseEnrollment.enrollments_for_user(user) + ] + + +def get_involved_threads(course_id, user_id): + """ + Return an iterator of all the discussion-forum threads + against provided user and course + """ + page = 0 + involved_threads = [] + while len(involved_threads) > 0 or page == 0: + involved_threads = [ + Thread.find(id=thread["id"]).retrieve( + with_responses=True, recursive=True, mark_as_read=False + ) + for thread in Thread.search( + {"course_id": course_id, "user_id": user_id, "page": page} + ).collection + ] + yield from involved_threads + page += 1 + + +def get_authored_threads_and_comments(comment_user, enrolled_course_ids): + """ + Return an iterator of all the discussion-forum threads + and comments of provided user and course + """ + + for course_id in enrolled_course_ids: + involved_threads = get_involved_threads(course_id, comment_user.id) + for thread in involved_threads: + if thread["user_id"] == comment_user.id: + yield thread.to_dict() + + children_to_scan = ( + thread.get("children", []) + + thread.get("endorsed_responses", []) + + thread.get("non_endorsed_responses", []) + ) + + while children_to_scan: + child = children_to_scan.pop(0) + children_to_scan.extend(child["children"]) + if child["user_id"] == comment_user.id: + yield child + + +def update_comment_user_username(comment_user, new_username): + """ + Update username for discussion-forum comment-users via Forum APIs + """ + user_detail_url = comment_user.url_with_id(params={"id": comment_user.id}) + response_data = perform_forum_request( + "put", + user_detail_url, + data_or_params={"username": new_username}, + ) + if response_data["username"] != new_username: + raise UpdateFailedException(url=user_detail_url, new_username=new_username) + + +def update_thread_username(thread_id, new_username): + """ + Update username for discussion-forum threads via Forum APIs + """ + thread_detail_url = Thread.url_with_id(params={"id": thread_id}) + response_data = perform_forum_request( + "put", + thread_detail_url, + data_or_params={"username": new_username}, + ) + if response_data["username"] != new_username: + raise UpdateFailedException(url=thread_detail_url, new_username=new_username) + + +def update_comment_username(comment_id, new_username): + """ + Update username for discussion-forum comments via Forum APIs + """ + comment_detail_url = Comment.url_for_comments(params={"parent_id": comment_id}) + response_data = perform_forum_request( + "put", + comment_detail_url, + data_or_params={"username": new_username}, + ) + if response_data["username"] != new_username: + raise UpdateFailedException(url=comment_detail_url, new_username=new_username)