Adds username replacement API
New API replaces all copies of username in LMS a desired username. Requires user be in username_replacement_admin group. Should only be ran as a larger job to update usernames across all services, otherwise the system will be left in a broken state for those users.
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
USERNAME_REPLACEMENT_GROUP = "username_replacement_admin"
|
||||
|
||||
class CanDeactivateUser(permissions.BasePermission):
|
||||
"""
|
||||
@@ -23,3 +24,11 @@ class CanRetireUser(permissions.BasePermission):
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return request.user.has_perm('accounts.can_retire_user')
|
||||
|
||||
class CanReplaceUsername(permissions.BasePermission):
|
||||
"""
|
||||
Grants access to the Username Replacement API for anyone in the group,
|
||||
including the service user.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
return request.user.groups.filter(name=USERNAME_REPLACEMENT_GROUP).exists()
|
||||
|
||||
@@ -7,9 +7,11 @@ https://openedx.atlassian.net/wiki/display/TNL/User+API
|
||||
import datetime
|
||||
import logging
|
||||
from functools import wraps
|
||||
import uuid
|
||||
|
||||
import pytz
|
||||
from consent.models import DataSharingConsent
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate, get_user_model, logout
|
||||
from django.contrib.sites.models import Site
|
||||
@@ -27,6 +29,7 @@ from rest_framework import permissions, status
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from six import iteritems, text_type
|
||||
@@ -71,7 +74,7 @@ from ..models import (
|
||||
UserRetirementStatus
|
||||
)
|
||||
from .api import get_account_settings, update_account_settings
|
||||
from .permissions import CanDeactivateUser, CanRetireUser
|
||||
from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser
|
||||
from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer
|
||||
from .signals import (
|
||||
USER_RETIRE_LMS_CRITICAL,
|
||||
@@ -1002,3 +1005,159 @@ class AccountRetirementView(ViewSet):
|
||||
"""
|
||||
for entitlement in CourseEntitlement.objects.filter(user_id=user.id):
|
||||
entitlement.courseentitlementsupportdetail_set.all().update(comments='')
|
||||
|
||||
class UsernameReplacementView(APIView):
|
||||
"""
|
||||
WARNING: This API is only meant to be used as part of a larger job that
|
||||
updates usernames across all services. DO NOT run this alone or users will
|
||||
not match across the system and things will be broken.
|
||||
|
||||
API will recieve a list of current usernames and their requested new
|
||||
username. If their new username is taken, it will randomly assign a new username.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication, )
|
||||
permission_classes = (permissions.IsAuthenticated, CanReplaceUsername)
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
POST /api/user/v1/accounts/replace_usernames/
|
||||
{
|
||||
"username_mappings": [
|
||||
{"current_username_1": "desired_username_1"},
|
||||
{"current_username_2": "desired_username_2"}
|
||||
]
|
||||
}
|
||||
|
||||
**POST Parameters**
|
||||
|
||||
A POST request must include the following parameter.
|
||||
|
||||
* username_mappings: Required. A list of objects that map the current username (key)
|
||||
to the desired username (value)
|
||||
|
||||
**POST Response Values**
|
||||
|
||||
As long as data validation passes, the request will return a 200 with a new mapping
|
||||
of old usernames (key) to new username (value)
|
||||
|
||||
{
|
||||
"successful_replacements": [
|
||||
{"old_username_1": "new_username_1"}
|
||||
],
|
||||
"failed_replacements": [
|
||||
{"old_username_2": "new_username_2"}
|
||||
]
|
||||
}
|
||||
|
||||
TODO: Determine if we need an audit trail outside of logging and API response.
|
||||
"""
|
||||
|
||||
# (model_name, column_name)
|
||||
MODELS_WITH_USERNAME = (
|
||||
('auth.user', 'username'),
|
||||
('consent.DataSharingConsent', 'username'),
|
||||
('consent.HistoricalDataSharingConsent', 'username'),
|
||||
('credit.CreditEligibility', 'username'),
|
||||
('credit.CreditRequest', 'username'),
|
||||
('credit.CreditRequirementStatus', 'username'),
|
||||
('user_api.UserRetirementPartnerReportingStatus', 'original_username'),
|
||||
('user_api.UserRetirementStatus', 'original_username')
|
||||
)
|
||||
UNIQUE_SUFFIX_LENGTH = getattr(settings, 'SOCIAL_AUTH_UUID_LENGTH', 4)
|
||||
|
||||
username_mappings = request.data.get("username_mappings")
|
||||
replacement_locations = self._load_models(MODELS_WITH_USERNAME)
|
||||
|
||||
if not self._has_valid_schema(username_mappings):
|
||||
raise ValidationError("Request data does not match schema")
|
||||
|
||||
successful_replacements, failed_replacements = [], []
|
||||
|
||||
for username_pair in username_mappings:
|
||||
current_username = list(username_pair.keys())[0]
|
||||
desired_username = list(username_pair.values())[0]
|
||||
new_username = self._generate_unique_username(desired_username, suffix_length=UNIQUE_SUFFIX_LENGTH)
|
||||
successfully_replaced = self._replace_username_for_all_models(
|
||||
current_username,
|
||||
new_username,
|
||||
replacement_locations
|
||||
)
|
||||
if successfully_replaced:
|
||||
successful_replacements.append({current_username: new_username})
|
||||
else:
|
||||
failed_replacements.append({current_username: new_username})
|
||||
return Response(
|
||||
status=status.HTTP_200_OK,
|
||||
data={
|
||||
"successful_replacements": successful_replacements,
|
||||
"failed_replacements": failed_replacements
|
||||
}
|
||||
)
|
||||
|
||||
def _load_models(self, models_with_fields):
|
||||
""" Takes tuples that contain a model path and returns the list with a loaded version of the model """
|
||||
try:
|
||||
replacement_locations = [(apps.get_model(model), column) for (model, column) in models_with_fields]
|
||||
except LookupError:
|
||||
log.exception("Unable to load models for username replacement")
|
||||
raise
|
||||
return replacement_locations
|
||||
|
||||
def _has_valid_schema(self, post_data):
|
||||
""" Verifies the data is a list of objects with a single key:value pair """
|
||||
if not isinstance(post_data, list):
|
||||
return False
|
||||
for obj in post_data:
|
||||
if not (isinstance(obj, dict) and len(obj) == 1):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _generate_unique_username(self, desired_username, suffix_length=4):
|
||||
""" Accepts a username and returns a unique username if the requested is taken """
|
||||
User = apps.get_model('auth.user')
|
||||
new_username = desired_username
|
||||
# Keep checking usernames in case desired_username + random suffix is already taken
|
||||
while True:
|
||||
if User.objects.filter(username=new_username).exists():
|
||||
unique_suffix = uuid.uuid4().hex[:suffix_length]
|
||||
new_username = desired_username + unique_suffix
|
||||
else:
|
||||
break
|
||||
return new_username
|
||||
|
||||
def _replace_username_for_all_models(self, current_username, new_username, replacement_locations):
|
||||
"""
|
||||
Replaces current_username with new_username for all (model, column) pairs in replacement locations.
|
||||
Returns if it was successful or not. Will return successful even if no matching
|
||||
|
||||
TODO: Determine if logs of username are a PII issue.
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
num_rows_changed = 0
|
||||
for (model, column) in replacement_locations:
|
||||
num_rows_changed += model.objects.filter(
|
||||
**{column: current_username}
|
||||
).update(
|
||||
**{column: new_username}
|
||||
)
|
||||
except Exception as exc:
|
||||
log.exception("Unable to change username from {current} to {new}. Reason: {error}".format(
|
||||
current=current_username,
|
||||
new=new_username,
|
||||
error=exc
|
||||
))
|
||||
return False
|
||||
if num_rows_changed == 0:
|
||||
log.warning("Unable to change username from {current} to {new} because {current} doesn't exist.".format(
|
||||
current=current_username,
|
||||
new=new_username,
|
||||
))
|
||||
return False
|
||||
|
||||
log.info("Successfully changed username from {current} to {new}.".format(
|
||||
current=current_username,
|
||||
new=new_username,
|
||||
))
|
||||
return True
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ from .accounts.views import (
|
||||
AccountRetirementView,
|
||||
AccountViewSet,
|
||||
DeactivateLogoutView,
|
||||
LMSAccountRetirementView
|
||||
LMSAccountRetirementView,
|
||||
UsernameReplacementView
|
||||
)
|
||||
from .preferences.views import PreferencesDetailView, PreferencesView
|
||||
from .verification_api.views import IDVerificationStatusView
|
||||
@@ -150,6 +151,11 @@ urlpatterns = [
|
||||
RETIREMENT_UPDATE,
|
||||
name='accounts_retirement_update'
|
||||
),
|
||||
url(
|
||||
r'^v1/accounts/replace_usernames/$',
|
||||
UsernameReplacementView.as_view(),
|
||||
name='username_replacement'
|
||||
),
|
||||
url(
|
||||
r'^v1/validation/registration$',
|
||||
RegistrationValidationView.as_view(),
|
||||
|
||||
Reference in New Issue
Block a user