feat: linting only (#35370)

Editing this file is going to cause a lot of automatic linting, so lint-only commit for nicer code review.
This commit is contained in:
Deborah Kaplan
2024-08-26 10:20:03 -04:00
committed by GitHub
parent 353dc34d9c
commit 0f177e4666

View File

@@ -21,7 +21,6 @@ from django.utils.translation import gettext as _
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 @@ from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=
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 import accounts
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 @@ from ..models import (
RetirementStateError,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus
UserRetirementStatus,
)
from .api import get_account_settings, update_account_settings
from .permissions import (
@@ -83,13 +82,13 @@ from .permissions import (
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 @@ from .utils import create_retirement_request_and_deactivate_account, username_su
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 request_requires_username(function):
class AccountViewSet(ViewSet):
"""
**Use Cases**
**Use Cases**
Get or update a user's account information. Updates are supported
only through merge patch.
Get or update a user's account information. Updates are supported
only through merge patch.
**Example Requests**
**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]
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"
PATCH /api/user/v1/accounts/{username}/{"key":"value"} "application/merge-patch+json"
POST /api/user/v1/accounts/search_emails "application/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 ("").
**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.
**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:
Otherwise, an HTTP 200 "OK" response is returned. The response
contains the following value:
* username: The username associated with the account.
* username: The username associated with the account.
**Response Values for GET requests to /accounts endpoints**
**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 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.
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:
* 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"
* 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:
* 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".
* "code": string ISO 639-1 language code e.g. "en".
* level_of_education: One of the following values:
* 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
* "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.
* 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.
* "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:
* 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
* "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.
* 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.
* 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.
* 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.
* 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.
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.
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.
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**
**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.
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 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 "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 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 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.
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 @@ class AccountViewSet(ViewSet):
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 @@ class AccountViewSet(ViewSet):
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 @@ class AccountViewSet(ViewSet):
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 @@ class AccountViewSet(ViewSet):
"""
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 @@ class AccountViewSet(ViewSet):
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 @@ class AccountViewSet(ViewSet):
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 @@ class NameChangeView(ViewSet):
}
"""
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 @@ class NameChangeView(ViewSet):
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 @@ class DeactivateLogoutView(APIView):
# 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 @@ class DeactivateLogoutView(APIView):
# 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 @@ class DeactivateLogoutView(APIView):
)
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 @@ class DeactivateLogoutView(APIView):
"""
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 @@ class DeactivateLogoutView(APIView):
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 @@ class DeactivateLogoutView(APIView):
"""
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 @@ class DeactivateLogoutView(APIView):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
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 @@ class AccountRetirementPartnerReportView(ViewSet):
# 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 @@ class CancelAccountRetirementStatusView(ViewSet):
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 @@ class AccountRetirementStatusView(ViewSet):
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 @@ class AccountRetirementStatusView(ViewSet):
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 @@ class AccountRetirementStatusView(ViewSet):
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 @@ class AccountRetirementStatusView(ViewSet):
"""
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 @@ class AccountRetirementStatusView(ViewSet):
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 @@ class AccountRetirementStatusView(ViewSet):
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 @@ class LMSAccountRetirementView(ViewSet):
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 @@ class LMSAccountRetirementView(ViewSet):
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 @@ class AccountRetirementView(ViewSet):
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 @@ class AccountRetirementView(ViewSet):
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 @@ class AccountRetirementView(ViewSet):
@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 @@ class AccountRetirementView(ViewSet):
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 @@ class UsernameReplacementView(APIView):
# (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 @@ class UsernameReplacementView(APIView):
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 @@ class UsernameReplacementView(APIView):
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 @@ class UsernameReplacementView(APIView):
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 @@ class UsernameReplacementView(APIView):
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 @@ class UsernameReplacementView(APIView):
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 @@ class UsernameReplacementView(APIView):
current_username,
new_username,
model.__class__.__name__, # Retrieves the model name that it failed on
exc
exc,
)
return False
if num_rows_changed == 0: