From 0f177e466639bc8e0dc4acece00a7e82e7ae6001 Mon Sep 17 00:00:00 2001 From: Deborah Kaplan Date: Mon, 26 Aug 2024 10:20:03 -0400 Subject: [PATCH] feat: linting only (#35370) Editing this file is going to cause a lot of automatic linting, so lint-only commit for nicer code review. --- .../djangoapps/user_api/accounts/views.py | 723 +++++++++--------- 1 file changed, 353 insertions(+), 370 deletions(-) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 720b3ba96af7..103c5bf24f6c 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -21,7 +21,6 @@ from edx_ace import ace from edx_ace.recipient import Recipient from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from openedx.core.lib.api.authentication import BearerAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit @@ -50,9 +49,10 @@ get_retired_email_by_email, get_retired_username_by_username, is_email_retired, - is_username_retired + is_username_retired, ) from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_pending_name_change +from lms.djangoapps.certificates.api import clear_pii_from_certificate_records_for_user from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments @@ -64,9 +64,8 @@ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError -from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.parsers import MergePatchParser -from lms.djangoapps.certificates.api import clear_pii_from_certificate_records_for_user from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound from ..message_types import DeletionNotificationMessage @@ -75,7 +74,7 @@ RetirementStateError, UserOrgTag, UserRetirementPartnerReportingStatus, - UserRetirementStatus + UserRetirementStatus, ) from .api import get_account_settings, update_account_settings from .permissions import ( @@ -83,13 +82,13 @@ CanDeactivateUser, CanGetAccountInfo, CanReplaceUsername, - CanRetireUser + CanRetireUser, ) from .serializers import ( PendingNameChangeSerializer, UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer, - UserSearchEmailSerializer + UserSearchEmailSerializer, ) from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator @@ -97,16 +96,16 @@ log = logging.getLogger(__name__) USER_PROFILE_PII = { - 'name': '', - 'meta': '', - 'location': '', - 'year_of_birth': None, - 'gender': None, - 'mailing_address': None, - 'city': None, - 'country': None, - 'bio': None, - 'phone_number': None, + "name": "", + "meta": "", + "location": "", + "year_of_birth": None, + "gender": None, + "mailing_address": None, + "city": None, + "country": None, + "bio": None, + "phone_number": None, } @@ -118,12 +117,9 @@ def request_requires_username(function): @wraps(function) def wrapper(self, request): # pylint: disable=missing-docstring - username = request.data.get('username', None) + username = request.data.get("username", None) if not username: - return Response( - status=status.HTTP_404_NOT_FOUND, - data={'message': 'The user was not specified.'} - ) + return Response(status=status.HTTP_404_NOT_FOUND, data={"message": "The user was not specified."}) return function(self, request) return wrapper @@ -131,177 +127,183 @@ def wrapper(self, request): # pylint: disable=missing-docstring class AccountViewSet(ViewSet): """ - **Use Cases** - - Get or update a user's account information. Updates are supported - only through merge patch. - - **Example Requests** - - GET /api/user/v1/me[?view=shared] - GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared] - GET /api/user/v1/accounts?email={user_email} - GET /api/user/v1/accounts/{username}/[?view=shared] - - PATCH /api/user/v1/accounts/{username}/{"key":"value"} "application/merge-patch+json" - - POST /api/user/v1/accounts/search_emails "application/json" - - **Notes for PATCH requests to /accounts endpoints** - * Requested updates to social_links are automatically merged with - previously set links. That is, any newly introduced platforms are - add to the previous list. Updated links to pre-existing platforms - replace their values in the previous list. Pre-existing platforms - can be removed by setting the value of the social_link to an - empty string (""). - - **Response Values for GET requests to the /me endpoint** - If the user is not logged in, an HTTP 401 "Not Authorized" response - is returned. - - Otherwise, an HTTP 200 "OK" response is returned. The response - contains the following value: - - * username: The username associated with the account. - - **Response Values for GET requests to /accounts endpoints** - - If no user exists with the specified username, or email, an HTTP 404 "Not - Found" response is returned. - - If the user makes the request for her own account, or makes a - request for another account and has "is_staff" access, an HTTP 200 - "OK" response is returned. The response contains the following - values. - - * id: numerical lms user id in db - * activation_key: auto-genrated activation key when signed up via email - * bio: null or textual representation of user biographical - information ("about me"). - * country: An ISO 3166 country code or null. - * date_joined: The date the account was created, in the string - format provided by datetime. For example, "2014-08-26T17:52:11Z". - * last_login: The latest date the user logged in, in the string datetime format. - * email: Email address for the user. New email addresses must be confirmed - via a confirmation email, so GET does not reflect the change until - the address has been confirmed. - * secondary_email: A secondary email address for the user. Unlike - the email field, GET will reflect the latest update to this field - even if changes have yet to be confirmed. - * verified_name: Approved verified name of the learner present in name affirmation plugin - * gender: One of the following values: - - * null - * "f" - * "m" - * "o" - - * goals: The textual representation of the user's goals, or null. - * is_active: Boolean representation of whether a user is active. - * language: The user's preferred language, or null. - * language_proficiencies: Array of language preferences. Each - preference is a JSON object with the following keys: - - * "code": string ISO 639-1 language code e.g. "en". - - * level_of_education: One of the following values: - - * "p": PhD or Doctorate - * "m": Master's or professional degree - * "b": Bachelor's degree - * "a": Associate's degree - * "hs": Secondary/high school - * "jhs": Junior secondary/junior high/middle school - * "el": Elementary/primary school - * "none": None - * "o": Other - * null: The user did not enter a value - - * mailing_address: The textual representation of the user's mailing - address, or null. - * name: The full name of the user. - * profile_image: A JSON representation of a user's profile image - information. This representation has the following keys. - - * "has_image": Boolean indicating whether the user has a profile - image. - * "image_url_*": Absolute URL to various sizes of a user's - profile image, where '*' matches a representation of the - corresponding image size, such as 'small', 'medium', 'large', - and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP. - - * requires_parental_consent: True if the user is a minor - requiring parental consent. - * social_links: Array of social links, sorted alphabetically by - "platform". Each preference is a JSON object with the following keys: - - * "platform": A particular social platform, ex: 'facebook' - * "social_link": The link to the user's profile on the particular platform - - * username: The username associated with the account. - * year_of_birth: The year the user was born, as an integer, or null. - - * account_privacy: The user's setting for sharing her personal - profile. Possible values are "all_users", "private", or "custom". - If "custom", the user has selectively chosen a subset of shareable - fields to make visible to others via the User Preferences API. - - * phone_number: The phone number for the user. String of numbers with - an optional `+` sign at the start. - - * pending_name_change: If the user has an active name change request, returns the - requested name. - - For all text fields, plain text instead of HTML is supported. The - data is stored exactly as specified. Clients must HTML escape - rendered values to avoid script injections. - - If a user who does not have "is_staff" access requests account - information for a different user, only a subset of these fields is - returned. The returned fields depend on the - ACCOUNT_VISIBILITY_CONFIGURATION configuration setting and the - visibility preference of the user for whom data is requested. - - Note that a user can view which account fields they have shared - with other users by requesting their own username and providing - the "view=shared" URL parameter. - - **Response Values for PATCH** - - Users can only modify their own account information. If the - requesting user does not have the specified username and has staff - access, the request returns an HTTP 403 "Forbidden" response. If - the requesting user does not have staff access, the request - returns an HTTP 404 "Not Found" response to avoid revealing the - existence of the account. - - If no user exists with the specified username, an HTTP 404 "Not - Found" response is returned. - - If "application/merge-patch+json" is not the specified content - type, a 415 "Unsupported Media Type" response is returned. - - If validation errors prevent the update, this method returns a 400 - "Bad Request" response that includes a "field_errors" field that - lists all error messages. - - If a failure at the time of the update prevents the update, a 400 - "Bad Request" error is returned. The JSON collection contains - specific errors. - - If the update is successful, updated user account data is returned. + **Use Cases** + + Get or update a user's account information. Updates are supported + only through merge patch. + + **Example Requests** + + GET /api/user/v1/me[?view=shared] + GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared] + GET /api/user/v1/accounts?email={user_email} + GET /api/user/v1/accounts/{username}/[?view=shared] + + PATCH /api/user/v1/accounts/{username}/{"key":"value"} "application/merge-patch+json" + + POST /api/user/v1/accounts/search_emails "application/json" + + **Notes for PATCH requests to /accounts endpoints** + * Requested updates to social_links are automatically merged with + previously set links. That is, any newly introduced platforms are + add to the previous list. Updated links to pre-existing platforms + replace their values in the previous list. Pre-existing platforms + can be removed by setting the value of the social_link to an + empty string (""). + + **Response Values for GET requests to the /me endpoint** + If the user is not logged in, an HTTP 401 "Not Authorized" response + is returned. + + Otherwise, an HTTP 200 "OK" response is returned. The response + contains the following value: + + * username: The username associated with the account. + + **Response Values for GET requests to /accounts endpoints** + + If no user exists with the specified username, or email, an HTTP 404 "Not + Found" response is returned. + + If the user makes the request for her own account, or makes a + request for another account and has "is_staff" access, an HTTP 200 + "OK" response is returned. The response contains the following + values. + + * id: numerical lms user id in db + * activation_key: auto-genrated activation key when signed up via email + * bio: null or textual representation of user biographical + information ("about me"). + * country: An ISO 3166 country code or null. + * date_joined: The date the account was created, in the string + format provided by datetime. For example, "2014-08-26T17:52:11Z". + * last_login: The latest date the user logged in, in the string datetime format. + * email: Email address for the user. New email addresses must be confirmed + via a confirmation email, so GET does not reflect the change until + the address has been confirmed. + * secondary_email: A secondary email address for the user. Unlike + the email field, GET will reflect the latest update to this field + even if changes have yet to be confirmed. + * verified_name: Approved verified name of the learner present in name affirmation plugin + * gender: One of the following values: + + * null + * "f" + * "m" + * "o" + + * goals: The textual representation of the user's goals, or null. + * is_active: Boolean representation of whether a user is active. + * language: The user's preferred language, or null. + * language_proficiencies: Array of language preferences. Each + preference is a JSON object with the following keys: + + * "code": string ISO 639-1 language code e.g. "en". + + * level_of_education: One of the following values: + + * "p": PhD or Doctorate + * "m": Master's or professional degree + * "b": Bachelor's degree + * "a": Associate's degree + * "hs": Secondary/high school + * "jhs": Junior secondary/junior high/middle school + * "el": Elementary/primary school + * "none": None + * "o": Other + * null: The user did not enter a value + + * mailing_address: The textual representation of the user's mailing + address, or null. + * name: The full name of the user. + * profile_image: A JSON representation of a user's profile image + information. This representation has the following keys. + + * "has_image": Boolean indicating whether the user has a profile + image. + * "image_url_*": Absolute URL to various sizes of a user's + profile image, where '*' matches a representation of the + corresponding image size, such as 'small', 'medium', 'large', + and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP. + + * requires_parental_consent: True if the user is a minor + requiring parental consent. + * social_links: Array of social links, sorted alphabetically by + "platform". Each preference is a JSON object with the following keys: + + * "platform": A particular social platform, ex: 'facebook' + * "social_link": The link to the user's profile on the particular platform + + * username: The username associated with the account. + * year_of_birth: The year the user was born, as an integer, or null. + + * account_privacy: The user's setting for sharing her personal + profile. Possible values are "all_users", "private", or "custom". + If "custom", the user has selectively chosen a subset of shareable + fields to make visible to others via the User Preferences API. + + * phone_number: The phone number for the user. String of numbers with + an optional `+` sign at the start. + + * pending_name_change: If the user has an active name change request, returns the + requested name. + + For all text fields, plain text instead of HTML is supported. The + data is stored exactly as specified. Clients must HTML escape + rendered values to avoid script injections. + + If a user who does not have "is_staff" access requests account + information for a different user, only a subset of these fields is + returned. The returned fields depend on the + ACCOUNT_VISIBILITY_CONFIGURATION configuration setting and the + visibility preference of the user for whom data is requested. + + Note that a user can view which account fields they have shared + with other users by requesting their own username and providing + the "view=shared" URL parameter. + + **Response Values for PATCH** + + Users can only modify their own account information. If the + requesting user does not have the specified username and has staff + access, the request returns an HTTP 403 "Forbidden" response. If + the requesting user does not have staff access, the request + returns an HTTP 404 "Not Found" response to avoid revealing the + existence of the account. + + If no user exists with the specified username, an HTTP 404 "Not + Found" response is returned. + + If "application/merge-patch+json" is not the specified content + type, a 415 "Unsupported Media Type" response is returned. + + If validation errors prevent the update, this method returns a 400 + "Bad Request" response that includes a "field_errors" field that + lists all error messages. + + If a failure at the time of the update prevents the update, a 400 + "Bad Request" error is returned. The JSON collection contains + specific errors. + + If the update is successful, updated user account data is returned. """ + authentication_classes = ( - JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, ) permission_classes = (permissions.IsAuthenticated, CanGetAccountInfo) - parser_classes = (JSONParser, MergePatchParser,) + parser_classes = ( + JSONParser, + MergePatchParser, + ) def get(self, request): """ GET /api/user/v1/me """ - return Response({'username': request.user.username}) + return Response({"username": request.user.username}) def list(self, request): """ @@ -309,13 +311,13 @@ def list(self, request): GET /api/user/v1/accounts?email={user_email} (Staff Only) GET /api/user/v1/accounts?lms_user_id={lms_user_id} (Staff Only) """ - usernames = request.GET.get('username') - user_email = request.GET.get('email') - lms_user_id = request.GET.get('lms_user_id') + usernames = request.GET.get("username") + user_email = request.GET.get("email") + lms_user_id = request.GET.get("lms_user_id") search_usernames = [] if usernames: - search_usernames = usernames.strip(',').split(',') + search_usernames = usernames.strip(",").split(",") elif user_email: if is_email_retired(user_email): can_cancel_retirement = True @@ -325,22 +327,20 @@ def list(self, request): retirement_status = UserRetirementStatus.objects.get( created__gt=earliest_datetime, created__lt=datetime.datetime.now(pytz.UTC), - original_email=user_email + original_email=user_email, ) retirement_id = retirement_status.id except UserRetirementStatus.DoesNotExist: can_cancel_retirement = False context = { - 'error_msg': accounts.RETIRED_EMAIL_MSG, - 'can_cancel_retirement': can_cancel_retirement, - 'retirement_id': retirement_id + "error_msg": accounts.RETIRED_EMAIL_MSG, + "can_cancel_retirement": can_cancel_retirement, + "retirement_id": retirement_id, } - return Response( - context, status=status.HTTP_404_NOT_FOUND - ) - user_email = user_email.strip('') + return Response(context, status=status.HTTP_404_NOT_FOUND) + user_email = user_email.strip("") try: user = User.objects.get(email=user_email) except (UserNotFound, User.DoesNotExist): @@ -355,9 +355,7 @@ def list(self, request): return Response(status=status.HTTP_400_BAD_REQUEST) search_usernames = [user.username] try: - account_settings = get_account_settings( - request, search_usernames, view=request.query_params.get('view') - ) + account_settings = get_account_settings(request, search_usernames, view=request.query_params.get("view")) except UserNotFound: return Response(status=status.HTTP_404_NOT_FOUND) @@ -386,23 +384,15 @@ def search_emails(self, request): """ if not request.user.is_staff: return Response( - { - 'developer_message': 'not_found', - 'user_message': 'Not Found' - }, - status=status.HTTP_404_NOT_FOUND + {"developer_message": "not_found", "user_message": "Not Found"}, status=status.HTTP_404_NOT_FOUND ) try: - user_emails = request.data['emails'] + user_emails = request.data["emails"] except KeyError as error: - error_message = f'{error} field is required' + error_message = f"{error} field is required" return Response( - { - 'developer_message': error_message, - 'user_message': error_message - }, - status=status.HTTP_400_BAD_REQUEST + {"developer_message": error_message, "user_message": error_message}, status=status.HTTP_400_BAD_REQUEST ) users = User.objects.filter(email__in=user_emails) data = UserSearchEmailSerializer(users, many=True).data @@ -413,8 +403,7 @@ def retrieve(self, request, username): GET /api/user/v1/accounts/{username}/ """ try: - account_settings = get_account_settings( - request, [username], view=request.query_params.get('view')) + account_settings = get_account_settings(request, [username], view=request.query_params.get("view")) except UserNotFound: return Response(status=status.HTTP_404_NOT_FOUND) @@ -443,11 +432,8 @@ def partial_update(self, request, username): return Response({"field_errors": err.field_errors}, status=status.HTTP_400_BAD_REQUEST) except AccountUpdateError as err: return Response( - { - "developer_message": err.developer_message, - "user_message": err.user_message - }, - status=status.HTTP_400_BAD_REQUEST + {"developer_message": err.developer_message, "user_message": err.user_message}, + status=status.HTTP_400_BAD_REQUEST, ) return Response(account_settings) @@ -457,6 +443,7 @@ class NameChangeView(ViewSet): """ Viewset to manage profile name change requests. """ + permission_classes = (permissions.IsAuthenticated,) def create(self, request): @@ -472,10 +459,10 @@ def create(self, request): } """ user = request.user - new_name = request.data.get('name', None) - rationale = f'Name change requested through account API by {user.username}' + new_name = request.data.get("name", None) + rationale = f"Name change requested through account API by {user.username}" - serializer = PendingNameChangeSerializer(data={'new_name': new_name}) + serializer = PendingNameChangeSerializer(data={"new_name": new_name}) if serializer.is_valid(): pending_name_change = do_name_change_request(user, new_name, rationale)[0] @@ -483,8 +470,8 @@ def create(self, request): return Response(status=status.HTTP_201_CREATED) else: return Response( - {'new_name': 'The profile name given was identical to the current name.'}, - status=status.HTTP_400_BAD_REQUEST + {"new_name": "The profile name given was identical to the current name."}, + status=status.HTTP_400_BAD_REQUEST, ) return Response(status=status.HTTP_400_BAD_REQUEST, data=serializer.errors) @@ -514,6 +501,7 @@ class AccountDeactivationView(APIView): Account deactivation viewset. Currently only supports POST requests. Only admins can deactivate accounts. """ + permission_classes = (permissions.IsAuthenticated, CanDeactivateUser) def post(self, request, username): @@ -559,6 +547,7 @@ class DeactivateLogoutView(APIView): - Log the user out - Create a row in the retirement table for that user """ + # BearerAuthentication is added here to support account deletion # from the mobile app until it moves to JWT Auth. # See mobile roadmap issue https://github.com/openedx/edx-platform/issues/33307. @@ -575,7 +564,7 @@ def post(self, request): # Ensure the account deletion is not disable enable_account_deletion = configuration_helpers.get_value( - 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) + "ENABLE_ACCOUNT_DELETION", settings.FEATURES.get("ENABLE_ACCOUNT_DELETION", False) ) if not enable_account_deletion: @@ -595,11 +584,9 @@ def post(self, request): # Send notification email to user site = Site.objects.get_current() notification_context = get_base_template_context(site) - notification_context.update({'full_name': request.user.profile.name}) + notification_context.update({"full_name": request.user.profile.name}) language_code = request.user.preferences.model.get_value( - request.user, - LANGUAGE_KEY, - default=settings.LANGUAGE_CODE + request.user, LANGUAGE_KEY, default=settings.LANGUAGE_CODE ) notification = DeletionNotificationMessage().personalize( recipient=Recipient(lms_user_id=0, email_address=user_email), @@ -608,22 +595,20 @@ def post(self, request): ) ace.send(notification) except Exception as exc: - log.exception('Error sending out deletion notification email') + log.exception("Error sending out deletion notification email") raise exc # Log the user out. logout(request) return Response(status=status.HTTP_204_NO_CONTENT) except KeyError: - log.exception(f'Username not specified {request.user}') - return Response('Username not specified.', status=status.HTTP_404_NOT_FOUND) + log.exception(f"Username not specified {request.user}") + return Response("Username not specified.", status=status.HTTP_404_NOT_FOUND) except user_model.DoesNotExist: log.exception(f'The user "{request.user.username}" does not exist.') - return Response( - f'The user "{request.user.username}" does not exist.', status=status.HTTP_404_NOT_FOUND - ) + return Response(f'The user "{request.user.username}" does not exist.', status=status.HTTP_404_NOT_FOUND) except Exception as exc: # pylint: disable=broad-except - log.exception(f'500 error deactivating account {exc}') + log.exception(f"500 error deactivating account {exc}") return Response(str(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) def _verify_user_password(self, request): @@ -636,7 +621,7 @@ def _verify_user_password(self, request): """ try: self._check_excessive_login_attempts(request.user) - user = authenticate(username=request.user.username, password=request.POST['password'], request=request) + user = authenticate(username=request.user.username, password=request.POST["password"], request=request) if user: if LoginFailures.is_feature_enabled(): LoginFailures.clear_lockout_counter(user) @@ -644,9 +629,7 @@ def _verify_user_password(self, request): else: self._handle_failed_authentication(request.user) except AuthFailedError as err: - log.exception( - f"The user password to deactivate was incorrect. {request.user.username}" - ) + log.exception(f"The user password to deactivate was incorrect. {request.user.username}") return Response(str(err), status=status.HTTP_403_FORBIDDEN) except Exception as err: # pylint: disable=broad-except return Response(f"Could not verify user password: {err}", status=status.HTTP_400_BAD_REQUEST) @@ -657,8 +640,9 @@ def _check_excessive_login_attempts(self, user): """ if user and LoginFailures.is_feature_enabled(): if LoginFailures.is_user_locked_out(user): - raise AuthFailedError(_('This account has been temporarily locked due ' - 'to excessive login failures. Try again later.')) + raise AuthFailedError( + _("This account has been temporarily locked due to excessive login failures. Try again later.") + ) def _handle_failed_authentication(self, user): """ @@ -667,7 +651,7 @@ def _handle_failed_authentication(self, user): if user and LoginFailures.is_feature_enabled(): LoginFailures.increment_lockout_counter(user) - raise AuthFailedError(_('Email or password is incorrect.')) + raise AuthFailedError(_("Email or password is incorrect.")) def _set_unusable_password(user): @@ -684,15 +668,19 @@ class AccountRetirementPartnerReportView(ViewSet): Provides API endpoints for managing partner reporting of retired users. """ - DELETION_COMPLETED_KEY = 'deletion_completed' - ORGS_CONFIG_KEY = 'orgs_config' - ORGS_CONFIG_ORG_KEY = 'org' - ORGS_CONFIG_FIELD_HEADINGS_KEY = 'field_headings' - ORIGINAL_EMAIL_KEY = 'original_email' - ORIGINAL_NAME_KEY = 'original_name' - STUDENT_ID_KEY = 'student_id' - - permission_classes = (permissions.IsAuthenticated, CanRetireUser,) + + DELETION_COMPLETED_KEY = "deletion_completed" + ORGS_CONFIG_KEY = "orgs_config" + ORGS_CONFIG_ORG_KEY = "org" + ORGS_CONFIG_FIELD_HEADINGS_KEY = "field_headings" + ORIGINAL_EMAIL_KEY = "original_email" + ORIGINAL_NAME_KEY = "original_name" + STUDENT_ID_KEY = "student_id" + + permission_classes = ( + permissions.IsAuthenticated, + CanRetireUser, + ) parser_classes = (JSONParser,) serializer_class = UserRetirementStatusSerializer @@ -706,7 +694,7 @@ def _get_orgs_for_user(user): org = enrollment.course_id.org # Org can conceivably be blank or this bogus default value - if org and org != 'outdated_entry': + if org and org != "outdated_entry": orgs.add(org) return orgs @@ -718,9 +706,9 @@ def retirement_partner_report(self, request): # pylint: disable=unused-argument that are not already being processed and updates their status to indicate they are currently being processed. """ - retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter( - is_being_processed=False - ).order_by('id') + retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter(is_being_processed=False).order_by( + "id" + ) retirements = [] for retirement_status in retirement_statuses: @@ -737,12 +725,12 @@ def _get_retirement_for_partner_report(self, retirement_status): Get the retirement for this retirement_status. The retirement info will be included in the partner report. """ retirement = { - 'user_id': retirement_status.user.pk, - 'original_username': retirement_status.original_username, + "user_id": retirement_status.user.pk, + "original_username": retirement_status.original_username, AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY: retirement_status.original_email, AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY: retirement_status.original_name, - 'orgs': self._get_orgs_for_user(retirement_status.user), - 'created': retirement_status.created, + "orgs": self._get_orgs_for_user(retirement_status.user), + "created": retirement_status.created, } return retirement @@ -761,7 +749,7 @@ def retirement_partner_status_create(self, request): Creates a UserRetirementPartnerReportingStatus object for the given user as part of the retirement pipeline. """ - username = request.data['username'] + username = request.data["username"] try: retirement = UserRetirementStatus.get_retirement_for_retirement_action(username) @@ -771,10 +759,10 @@ def retirement_partner_status_create(self, request): UserRetirementPartnerReportingStatus.objects.get_or_create( user=retirement.user, defaults={ - 'original_username': retirement.original_username, - 'original_email': retirement.original_email, - 'original_name': retirement.original_name - } + "original_username": retirement.original_username, + "original_email": retirement.original_email, + "original_name": retirement.original_name, + }, ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -790,14 +778,13 @@ def retirement_partner_cleanup(self, request): Deletes UserRetirementPartnerReportingStatus objects for a list of users that have been reported on. """ - usernames = [u['original_username'] for u in request.data] + usernames = [u["original_username"] for u in request.data] if not usernames: - return Response('No original_usernames given.', status=status.HTTP_400_BAD_REQUEST) + return Response("No original_usernames given.", status=status.HTTP_400_BAD_REQUEST) retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter( - is_being_processed=True, - original_username__in=usernames + is_being_processed=True, original_username__in=usernames ) # Need to de-dupe usernames that differ only by case to find the exact right match @@ -809,15 +796,15 @@ def retirement_partner_cleanup(self, request): # to disambiguate them in Python, which will respect case in the comparison. if len(usernames) != len(retirement_statuses_clean): return Response( - '{} original_usernames given, {} found!\n' - 'Given usernames:\n{}\n' - 'Found UserRetirementReportingStatuses:\n{}'.format( + "{} original_usernames given, {} found!\n" + "Given usernames:\n{}\n" + "Found UserRetirementReportingStatuses:\n{}".format( len(usernames), len(retirement_statuses_clean), usernames, - ', '.join([rs.original_username for rs in retirement_statuses_clean]) + ", ".join([rs.original_username for rs in retirement_statuses_clean]), ), - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) retirement_statuses.delete() @@ -829,7 +816,11 @@ class CancelAccountRetirementStatusView(ViewSet): """ Provides API endpoints for canceling retirement process for a user's account. """ - permission_classes = (permissions.IsAuthenticated, CanCancelUserRetirement,) + + permission_classes = ( + permissions.IsAuthenticated, + CanCancelUserRetirement, + ) def cancel_retirement(self, request): """ @@ -839,26 +830,23 @@ def cancel_retirement(self, request): This also handles the top level error handling, and permissions. """ try: - retirement_id = request.data['retirement_id'] + retirement_id = request.data["retirement_id"] except KeyError: - return Response( - status=status.HTTP_400_BAD_REQUEST, - data={'message': 'retirement_id must be specified.'} - ) + return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": "retirement_id must be specified."}) try: retirement = UserRetirementStatus.objects.get(id=retirement_id) except UserRetirementStatus.DoesNotExist: - return Response(data={"message": 'Retirement does not exist!'}, status=status.HTTP_400_BAD_REQUEST) + return Response(data={"message": "Retirement does not exist!"}, status=status.HTTP_400_BAD_REQUEST) - if retirement.current_state.state_name != 'PENDING': + if retirement.current_state.state_name != "PENDING": return Response( status=status.HTTP_400_BAD_REQUEST, data={ "message": f"Retirement requests can only be cancelled for users in the PENDING state. Current " - f"request state for '{retirement.original_username}': " - f"{retirement.current_state.state_name}" - } + f"request state for '{retirement.original_username}': " + f"{retirement.current_state.state_name}" + }, ) handle_retirement_cancellation(retirement) @@ -870,7 +858,11 @@ class AccountRetirementStatusView(ViewSet): """ Provides API endpoints for managing the user retirement process. """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser,) + + permission_classes = ( + permissions.IsAuthenticated, + CanRetireUser, + ) parser_classes = (JSONParser,) serializer_class = UserRetirementStatusSerializer @@ -883,37 +875,35 @@ def retirement_queue(self, request): created in the retirement queue at least `cool_off_days` ago. """ try: - cool_off_days = int(request.GET['cool_off_days']) + cool_off_days = int(request.GET["cool_off_days"]) if cool_off_days < 0: - raise RetirementStateError('Invalid argument for cool_off_days, must be greater than 0.') + raise RetirementStateError("Invalid argument for cool_off_days, must be greater than 0.") - states = request.GET.getlist('states') + states = request.GET.getlist("states") if not states: raise RetirementStateError('Param "states" required with at least one state.') state_objs = RetirementState.objects.filter(state_name__in=states) if state_objs.count() != len(states): found = [s.state_name for s in state_objs] - raise RetirementStateError(f'Unknown state. Requested: {states} Found: {found}') + raise RetirementStateError(f"Unknown state. Requested: {states} Found: {found}") - limit = request.GET.get('limit') + limit = request.GET.get("limit") if limit: try: limit_count = int(limit) except ValueError: return Response( - f'Limit could not be parsed: {limit}, please ensure this is an integer', - status=status.HTTP_400_BAD_REQUEST + f"Limit could not be parsed: {limit}, please ensure this is an integer", + status=status.HTTP_400_BAD_REQUEST, ) earliest_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=cool_off_days) - retirements = UserRetirementStatus.objects.select_related( - 'user', 'current_state', 'last_state' - ).filter( - current_state__in=state_objs, created__lt=earliest_datetime - ).order_by( - 'id' + retirements = ( + UserRetirementStatus.objects.select_related("user", "current_state", "last_state") + .filter(current_state__in=state_objs, created__lt=earliest_datetime) + .order_by("id") ) if limit: retirements = retirements[:limit_count] @@ -921,10 +911,9 @@ def retirement_queue(self, request): return Response(serializer.data) # This should only occur on the int() conversion of cool_off_days at this point except ValueError: - return Response('Invalid cool_off_days, should be integer.', status=status.HTTP_400_BAD_REQUEST) + return Response("Invalid cool_off_days, should be integer.", status=status.HTTP_400_BAD_REQUEST) except KeyError as exc: - return Response(f'Missing required parameter: {str(exc)}', - status=status.HTTP_400_BAD_REQUEST) + return Response(f"Missing required parameter: {str(exc)}", status=status.HTTP_400_BAD_REQUEST) except RetirementStateError as exc: return Response(str(exc), status=status.HTTP_400_BAD_REQUEST) @@ -939,36 +928,33 @@ def retirements_by_status_and_date(self, request): so to get one day you would set both dates to that day. """ try: - start_date = datetime.datetime.strptime(request.GET['start_date'], '%Y-%m-%d').replace(tzinfo=pytz.UTC) - end_date = datetime.datetime.strptime(request.GET['end_date'], '%Y-%m-%d').replace(tzinfo=pytz.UTC) + start_date = datetime.datetime.strptime(request.GET["start_date"], "%Y-%m-%d").replace(tzinfo=pytz.UTC) + end_date = datetime.datetime.strptime(request.GET["end_date"], "%Y-%m-%d").replace(tzinfo=pytz.UTC) now = datetime.datetime.now(pytz.UTC) if start_date > now or end_date > now or start_date > end_date: - raise RetirementStateError('Dates must be today or earlier, and start must be earlier than end.') + raise RetirementStateError("Dates must be today or earlier, and start must be earlier than end.") # Add a day to make sure we get all the way to 23:59:59.999, this is compared "lt" in the query # not "lte". end_date += datetime.timedelta(days=1) - state = request.GET['state'] + state = request.GET["state"] state_obj = RetirementState.objects.get(state_name=state) - retirements = UserRetirementStatus.objects.select_related( - 'user', 'current_state', 'last_state', 'user__profile' - ).filter( - current_state=state_obj, created__lt=end_date, created__gte=start_date - ).order_by( - 'id' + retirements = ( + UserRetirementStatus.objects.select_related("user", "current_state", "last_state", "user__profile") + .filter(current_state=state_obj, created__lt=end_date, created__gte=start_date) + .order_by("id") ) serializer = UserRetirementStatusSerializer(retirements, many=True) return Response(serializer.data) # This should only occur on the datetime conversion of the start / end dates. except ValueError as exc: - return Response(f'Invalid start or end date: {str(exc)}', status=status.HTTP_400_BAD_REQUEST) + return Response(f"Invalid start or end date: {str(exc)}", status=status.HTTP_400_BAD_REQUEST) except KeyError as exc: - return Response(f'Missing required parameter: {str(exc)}', - status=status.HTTP_400_BAD_REQUEST) + return Response(f"Missing required parameter: {str(exc)}", status=status.HTTP_400_BAD_REQUEST) except RetirementState.DoesNotExist: - return Response('Unknown retirement state.', status=status.HTTP_400_BAD_REQUEST) + return Response("Unknown retirement state.", status=status.HTTP_400_BAD_REQUEST) except RetirementStateError as exc: return Response(str(exc), status=status.HTTP_400_BAD_REQUEST) @@ -980,9 +966,9 @@ def retrieve(self, request, username): # pylint: disable=unused-argument """ try: user = get_potentially_retired_user_by_username(username) - retirement = UserRetirementStatus.objects.select_related( - 'user', 'current_state', 'last_state' - ).get(user=user) + retirement = UserRetirementStatus.objects.select_related("user", "current_state", "last_state").get( + user=user + ) serializer = UserRetirementStatusSerializer(instance=retirement) return Response(serializer.data) except (UserRetirementStatus.DoesNotExist, User.DoesNotExist): @@ -1008,7 +994,7 @@ def partial_update(self, request): The content type for this request is 'application/json'. """ try: - username = request.data['username'] + username = request.data["username"] retirements = UserRetirementStatus.objects.filter(original_username=username) # During a narrow window learners were able to re-use a username that had been retired if @@ -1049,20 +1035,19 @@ def cleanup(self, request): Deletes a batch of retirement requests by username. """ try: - usernames = request.data['usernames'] + usernames = request.data["usernames"] if not isinstance(usernames, list): - raise TypeError('Usernames should be an array.') + raise TypeError("Usernames should be an array.") - complete_state = RetirementState.objects.get(state_name='COMPLETE') + complete_state = RetirementState.objects.get(state_name="COMPLETE") retirements = UserRetirementStatus.objects.filter( - original_username__in=usernames, - current_state=complete_state + original_username__in=usernames, current_state=complete_state ) # Sanity check that they're all valid usernames in the right state if len(usernames) != len(retirements): - raise UserRetirementStatus.DoesNotExist('Not all usernames exist in the COMPLETE state.') + raise UserRetirementStatus.DoesNotExist("Not all usernames exist in the COMPLETE state.") retirements.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1076,7 +1061,11 @@ class LMSAccountRetirementView(ViewSet): """ Provides an API endpoint for retiring a user in the LMS. """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser,) + + permission_classes = ( + permissions.IsAuthenticated, + CanRetireUser, + ) parser_classes = (JSONParser,) @request_requires_username @@ -1093,13 +1082,13 @@ def post(self, request): Retires the user with the given username in the LMS. """ - username = request.data['username'] + username = request.data["username"] try: retirement = UserRetirementStatus.get_retirement_for_retirement_action(username) RevisionPluginRevision.retire_user(retirement.user) ArticleRevision.retire_user(retirement.user) - PendingNameChange.delete_by_user_value(retirement.user, field='user') + PendingNameChange.delete_by_user_value(retirement.user, field="user") ManualEnrollmentAudit.retire_manual_enrollments(retirement.user, retirement.retired_email) CreditRequest.retire_user(retirement) @@ -1115,7 +1104,7 @@ def post(self, request): sender=self.__class__, email=retirement.original_email, new_email=retirement.retired_email, - user=retirement.user + user=retirement.user, ) except UserRetirementStatus.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) @@ -1131,7 +1120,11 @@ class AccountRetirementView(ViewSet): """ Provides API endpoint for retiring a user. """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser,) + + permission_classes = ( + permissions.IsAuthenticated, + CanRetireUser, + ) parser_classes = (JSONParser,) @request_requires_username @@ -1148,7 +1141,7 @@ def post(self, request): Retires the user with the given username. This includes retiring this username, the associated email address, and any other PII associated with this user. """ - username = request.data['username'] + username = request.data["username"] try: retirement_status = UserRetirementStatus.get_retirement_for_retirement_action(username) @@ -1173,18 +1166,18 @@ def post(self, request): self.retire_entitlement_support_detail(user) # Retire misc. models that may contain PII of this user - PendingEmailChange.delete_by_user_value(user, field='user') - UserOrgTag.delete_by_user_value(user, field='user') + PendingEmailChange.delete_by_user_value(user, field="user") + UserOrgTag.delete_by_user_value(user, field="user") # Retire any objects linked to the user via their original email - CourseEnrollmentAllowed.delete_by_user_value(original_email, field='email') - UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field='email') + CourseEnrollmentAllowed.delete_by_user_value(original_email, field="email") + UnregisteredLearnerCohortAssignments.delete_by_user_value(original_email, field="email") # This signal allows code in higher points of LMS to retire the user as necessary USER_RETIRE_LMS_CRITICAL.send(sender=self.__class__, user=user) - user.first_name = '' - user.last_name = '' + user.first_name = "" + user.last_name = "" user.is_active = False user.username = retired_username user.save() @@ -1227,24 +1220,20 @@ def retire_users_data_sharing_consent(username, retired_username): @staticmethod def retire_sapsf_data_transmission(user): # lint-amnesty, pylint: disable=missing-function-docstring for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id): - for enrollment in EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user=ent_user - ): + for enrollment in EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user=ent_user): audits = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter( enterprise_course_enrollment_id=enrollment.id ) - audits.update(sapsf_user_id='') + audits.update(sapsf_user_id="") @staticmethod def retire_degreed_data_transmission(user): # lint-amnesty, pylint: disable=missing-function-docstring for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id): - for enrollment in EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user=ent_user - ): + for enrollment in EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user=ent_user): audits = DegreedLearnerDataTransmissionAudit.objects.filter( enterprise_course_enrollment_id=enrollment.id ) - audits.update(degreed_user_email='') + audits.update(degreed_user_email="") @staticmethod def retire_user_from_pending_enterprise_customer_user(user, retired_email): @@ -1256,7 +1245,7 @@ def retire_entitlement_support_detail(user): Updates all CourseEntitleSupportDetail records for the given user to have an empty ``comments`` field. """ for entitlement in CourseEntitlement.objects.filter(user_id=user.id): - entitlement.courseentitlementsupportdetail_set.all().update(comments='') + entitlement.courseentitlementsupportdetail_set.all().update(comments="") @staticmethod def clear_pii_from_certificate_records(user): @@ -1279,6 +1268,7 @@ class UsernameReplacementView(APIView): This API will be called first, before calling the APIs in other services as this one handles the checks on the usernames provided. """ + permission_classes = (permissions.IsAuthenticated, CanReplaceUsername) def post(self, request): @@ -1320,16 +1310,16 @@ def post(self, request): # (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') + ("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) + 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) @@ -1344,9 +1334,7 @@ def post(self, request): 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 + current_username, new_username, replacement_locations ) if successfully_replaced: successful_replacements.append({current_username: new_username}) @@ -1354,14 +1342,11 @@ def post(self, request): failed_replacements.append({current_username: new_username}) return Response( status=status.HTTP_200_OK, - data={ - "successful_replacements": successful_replacements, - "failed_replacements": failed_replacements - } + 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 """ + """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: @@ -1370,7 +1355,7 @@ def _load_models(self, models_with_fields): return replacement_locations def _has_valid_schema(self, post_data): - """ Verifies the data is a list of objects with a single key:value pair """ + """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: @@ -1389,7 +1374,7 @@ def _generate_unique_username(self, desired_username, suffix_length=4): while True: if User.objects.filter(username=new_username).exists(): # adding a dash between user-supplied and system-generated values to avoid weird combinations - new_username = desired_username + '-' + username_suffix_generator(suffix_length) + new_username = desired_username + "-" + username_suffix_generator(suffix_length) else: break return new_username @@ -1404,10 +1389,8 @@ def _replace_username_for_all_models(self, current_username, new_username, repla try: with transaction.atomic(): num_rows_changed = 0 - for (model, column) in replacement_locations: - num_rows_changed += model.objects.filter( - **{column: current_username} - ).update( + for model, column in replacement_locations: + num_rows_changed += model.objects.filter(**{column: current_username}).update( **{column: new_username} ) except Exception as exc: # pylint: disable=broad-except @@ -1416,7 +1399,7 @@ def _replace_username_for_all_models(self, current_username, new_username, repla current_username, new_username, model.__class__.__name__, # Retrieves the model name that it failed on - exc + exc, ) return False if num_rows_changed == 0: