/
views.py
1469 lines (1243 loc) · 60.4 KB
/
views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
An API for retrieving user account information.
For additional information and historical context, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API
"""
import datetime
import logging
from functools import wraps
import pytz
from consent.models import DataSharingConsent
from django.apps import apps
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model, logout
from django.contrib.sites.models import Site
from django.core.cache import cache
from django.db import transaction
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 edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser
from integrated_channels.degreed.models import DegreedLearnerDataTransmissionAudit
from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit
from rest_framework import permissions, status
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import UnsupportedMediaType
from rest_framework.parsers import JSONParser
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from wiki.models import ArticleRevision
from wiki.models.pluginbase import RevisionPluginRevision
from common.djangoapps.entitlements.models import CourseEntitlement
from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=unused-import
CourseEnrollmentAllowed,
LoginFailures,
ManualEnrollmentAudit,
PendingEmailChange,
PendingNameChange,
User,
UserProfile,
get_potentially_retired_user_by_username,
get_retired_email_by_email,
get_retired_username_by_username,
is_email_retired,
is_username_retired
)
from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_pending_name_change
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
from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.profile_images.images import remove_profile_images
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.parsers import MergePatchParser
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
from ..message_types import DeletionNotificationMessage
from ..models import (
RetirementState,
RetirementStateError,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus
)
from .api import get_account_settings, update_account_settings
from .permissions import (
CanCancelUserRetirement,
CanDeactivateUser,
CanGetAccountInfo,
CanReplaceUsername,
CanRetireUser
)
from .serializers import (
PendingNameChangeSerializer,
UserRetirementPartnerReportSerializer,
UserRetirementStatusSerializer,
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
try:
from coaching.api import has_ever_consented_to_coaching
except ImportError:
has_ever_consented_to_coaching = None
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,
}
def request_requires_username(function):
"""
Requires that a ``username`` key containing a truthy value exists in
the ``request.data`` attribute of the decorated function.
"""
@wraps(function)
def wrapper(self, request): # pylint: disable=missing-docstring
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 function(self, request)
return wrapper
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.
* accomplishments_shared: Signals whether badges are enabled on the
platform and should be fetched.
* 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
)
permission_classes = (permissions.IsAuthenticated, CanGetAccountInfo)
parser_classes = (JSONParser, MergePatchParser,)
def get(self, request):
"""
GET /api/user/v1/me
"""
return Response({'username': request.user.username})
def list(self, request):
"""
GET /api/user/v1/accounts?username={username1,username2}
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')
search_usernames = []
if usernames:
search_usernames = usernames.strip(',').split(',')
elif user_email:
if is_email_retired(user_email):
can_cancel_retirement = True
retirement_id = None
earliest_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=settings.COOL_OFF_DAYS)
try:
retirement_status = UserRetirementStatus.objects.get(
created__gt=earliest_datetime,
created__lt=datetime.datetime.now(pytz.UTC),
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
}
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):
return Response(status=status.HTTP_404_NOT_FOUND)
search_usernames = [user.username]
elif lms_user_id:
try:
user = User.objects.get(id=lms_user_id)
except (UserNotFound, User.DoesNotExist):
return Response(status=status.HTTP_404_NOT_FOUND)
except ValueError:
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')
)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(account_settings)
def search_emails(self, request):
"""
POST /api/user/v1/accounts/search_emails
Content Type: "application/json"
{
"emails": ["edx@example.com", "staff@example.com"]
}
Response:
[
{
"username": "edx",
"email": "edx@example.com",
"id": 3,
},
{
"username": "staff",
"email": "staff@example.com",
"id": 8,
}
]
"""
if not request.user.is_staff:
return Response(
{
'developer_message': 'not_found',
'user_message': 'Not Found'
},
status=status.HTTP_404_NOT_FOUND
)
try:
user_emails = request.data['emails']
except KeyError as error:
error_message = f'{error} field is required'
return Response(
{
'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
return Response(data)
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'))
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(account_settings[0])
def partial_update(self, request, username):
"""
PATCH /api/user/v1/accounts/{username}/
Note that this implementation is the "merge patch" implementation proposed in
https://tools.ietf.org/html/rfc7396. The content_type must be "application/merge-patch+json" or
else an error response with status code 415 will be returned.
"""
if request.content_type != MergePatchParser.media_type:
raise UnsupportedMediaType(request.content_type)
try:
with transaction.atomic():
update_account_settings(request.user, request.data, username=username)
account_settings = get_account_settings(request, [username])[0]
except UserNotAuthorized:
return Response(status=status.HTTP_403_FORBIDDEN)
except UserNotFound:
return Response(status=status.HTTP_404_NOT_FOUND)
except AccountValidationError as err:
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
)
return Response(account_settings)
class NameChangeView(ViewSet):
"""
Viewset to manage profile name change requests.
"""
authentication_classes = (JwtAuthentication, SessionAuthentication,)
permission_classes = (permissions.IsAuthenticated,)
def create(self, request):
"""
POST /api/user/v1/accounts/name_change/
Request a profile name change. This creates a PendingNameChange to be verified later,
rather than updating the user's profile name directly.
Example request:
{
"name": "Jon Doe"
}
"""
user = request.user
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})
if serializer.is_valid():
pending_name_change = do_name_change_request(user, new_name, rationale)[0]
if pending_name_change:
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
)
return Response(status=status.HTTP_400_BAD_REQUEST, data=serializer.errors)
def confirm(self, request, username):
"""
POST /api/user/v1/account/name_change/{username}/confirm
Confirm a name change request for the specified user, and update their profile name.
"""
if not request.user.is_staff:
return Response(status=status.HTTP_403_FORBIDDEN)
user_model = get_user_model()
user = user_model.objects.get(username=username)
pending_name_change = get_pending_name_change(user)
if pending_name_change:
confirm_name_change(user, pending_name_change)
return Response(status=status.HTTP_204_NO_CONTENT)
else:
return Response(status=status.HTTP_404_NOT_FOUND)
class AccountDeactivationView(APIView):
"""
Account deactivation viewset. Currently only supports POST requests.
Only admins can deactivate accounts.
"""
authentication_classes = (JwtAuthentication,)
permission_classes = (permissions.IsAuthenticated, CanDeactivateUser)
def post(self, request, username):
"""
POST /api/user/v1/accounts/{username}/deactivate/
Marks the user as having no password set for deactivation purposes.
"""
_set_unusable_password(User.objects.get(username=username))
return Response(get_account_settings(request, [username])[0])
class DeactivateLogoutView(APIView):
"""
POST /api/user/v1/accounts/deactivate_logout/
{
"password": "example_password",
}
**POST Parameters**
A POST request must include the following parameter.
* password: Required. The current password of the user being deactivated.
**POST Response Values**
If the request does not specify a username or submits a username
for a non-existent user, the request returns an HTTP 404 "Not Found"
response.
If a user who is not a superuser tries to deactivate a user,
the request returns an HTTP 403 "Forbidden" response.
If the specified user is successfully deactivated, the request
returns an HTTP 204 "No Content" response.
If an unanticipated error occurs, the request returns an
HTTP 500 "Internal Server Error" response.
Allows an LMS user to take the following actions:
- Change the user's password permanently to Django's unusable password
- Log the user out
- Create a row in the retirement table for that user
"""
authentication_classes = (JwtAuthentication, SessionAuthentication,)
permission_classes = (permissions.IsAuthenticated,)
def post(self, request):
"""
POST /api/user/v1/accounts/deactivate_logout/
Marks the user as having no password set for deactivation purposes,
and logs the user out.
"""
user_model = get_user_model()
try:
# Get the username from the request and check that it exists
verify_user_password_response = self._verify_user_password(request)
if verify_user_password_response.status_code != status.HTTP_204_NO_CONTENT:
return verify_user_password_response
with transaction.atomic():
user_email = request.user.email
create_retirement_request_and_deactivate_account(request.user)
try:
# 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})
language_code = request.user.preferences.model.get_value(
request.user,
LANGUAGE_KEY,
default=settings.LANGUAGE_CODE
)
notification = DeletionNotificationMessage().personalize(
recipient=Recipient(lms_user_id=0, email_address=user_email),
language=language_code,
user_context=notification_context,
)
ace.send(notification)
except Exception as exc:
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)
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
)
except Exception as exc: # pylint: disable=broad-except
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):
"""
If the user is logged in and we want to verify that they have submitted the correct password
for a major account change (for example, retiring this user's account).
Args:
request (HttpRequest): A request object where the password should be included in the POST fields.
"""
try:
self._check_excessive_login_attempts(request.user)
user = authenticate(username=request.user.username, password=request.POST['password'], request=request)
if user:
if LoginFailures.is_feature_enabled():
LoginFailures.clear_lockout_counter(user)
return Response(status=status.HTTP_204_NO_CONTENT)
else:
self._handle_failed_authentication(request.user)
except AuthFailedError as err:
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)
def _check_excessive_login_attempts(self, user):
"""
See if account has been locked out due to excessive login failures
"""
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.'))
def _handle_failed_authentication(self, user):
"""
Handles updating the failed login count, inactive user notifications, and logging failed authentications.
"""
if user and LoginFailures.is_feature_enabled():
LoginFailures.increment_lockout_counter(user)
raise AuthFailedError(_('Email or password is incorrect.'))
def _set_unusable_password(user):
"""
Helper method for the shared functionality of setting a user's
password to the unusable password, thus deactivating the account.
"""
user.set_unusable_password()
user.save()
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'
authentication_classes = (JwtAuthentication,)
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
parser_classes = (JSONParser,)
serializer_class = UserRetirementStatusSerializer
@staticmethod
def _get_orgs_for_user(user):
"""
Returns a set of orgs that the user has enrollments with
"""
orgs = set()
for enrollment in user.courseenrollment_set.all():
org = enrollment.course_id.org
# Org can conceivably be blank or this bogus default value
if org and org != 'outdated_entry':
orgs.add(org)
return orgs
def retirement_partner_report(self, request): # pylint: disable=unused-argument
"""
POST /api/user/v1/accounts/retirement_partner_report/
Returns the list of UserRetirementPartnerReportingStatus users
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')
retirements = []
for retirement_status in retirement_statuses:
retirements.append(self._get_retirement_for_partner_report(retirement_status))
serializer = UserRetirementPartnerReportSerializer(retirements, many=True)
retirement_statuses.update(is_being_processed=True)
return Response(serializer.data)
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,
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,
}
# Some orgs have a custom list of headings and content for the partner report. Add this, if applicable.
self._add_orgs_config_for_user(retirement, retirement_status.user)
return retirement
def _add_orgs_config_for_user(self, retirement, user):
"""
Check to see if the user's info was sent to any partners (orgs) that have a a custom list of headings and
content for the partner report. If so, add this.
"""
# See if the MicroBachelors coaching provider needs to be notified of this user's retirement
if has_ever_consented_to_coaching is not None and has_ever_consented_to_coaching(user):
# See if the user has a MicroBachelors external id. If not, they were never sent to the
# coaching provider.
external_ids = ExternalId.objects.filter(
user=user,
external_id_type__name=ExternalIdType.MICROBACHELORS_COACHING
)
if external_ids.exists():
# User has an external id. Add the additional info.
external_id = str(external_ids[0].external_user_id)
self._add_coaching_orgs_config(retirement, external_id)
def _add_coaching_orgs_config(self, retirement, external_id):
"""
Add the orgs configuration for MicroBachelors coaching
"""
# Add the custom field headings
retirement[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = [
{
AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching',
AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [
AccountRetirementPartnerReportView.STUDENT_ID_KEY,
AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY,
AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY,
AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY
]
}
]
# Add the custom field value
retirement[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = external_id
@request_requires_username
def retirement_partner_status_create(self, request):
"""
PUT /api/user/v1/accounts/retirement_partner_report/
```
{
'username': 'user_to_retire'
}
```
Creates a UserRetirementPartnerReportingStatus object for the given user
as part of the retirement pipeline.
"""
username = request.data['username']
try:
retirement = UserRetirementStatus.get_retirement_for_retirement_action(username)
orgs = self._get_orgs_for_user(retirement.user)
if orgs:
UserRetirementPartnerReportingStatus.objects.get_or_create(
user=retirement.user,
defaults={
'original_username': retirement.original_username,
'original_email': retirement.original_email,
'original_name': retirement.original_name
}
)
return Response(status=status.HTTP_204_NO_CONTENT)
except UserRetirementStatus.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
def retirement_partner_cleanup(self, request):
"""
POST /api/user/v1/accounts/retirement_partner_report_cleanup/
[{'original_username': 'user1'}, {'original_username': 'user2'}, ...]
Deletes UserRetirementPartnerReportingStatus objects for a list of users
that have been reported on.
"""
usernames = [u['original_username'] for u in request.data]
if not usernames:
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
)
# Need to de-dupe usernames that differ only by case to find the exact right match
retirement_statuses_clean = [rs for rs in retirement_statuses if rs.original_username in usernames]
# During a narrow window learners were able to re-use a username that had been retired if
# they altered the capitalization of one or more characters. Therefore we can have more
# than one row returned here (due to our MySQL collation being case-insensitive), and need
# 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(
len(usernames),
len(retirement_statuses_clean),
usernames,
', '.join([rs.original_username for rs in retirement_statuses_clean])
),
status=status.HTTP_400_BAD_REQUEST
)
retirement_statuses.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class CancelAccountRetirementStatusView(ViewSet):
"""
Provides API endpoints for canceling retirement process for a user's account.
"""
authentication_classes = (JwtAuthentication, SessionAuthentication)
permission_classes = (permissions.IsAuthenticated, CanCancelUserRetirement,)
def cancel_retirement(self, request):
"""
POST /api/user/v1/accounts/cancel_retirement/
Cancels the retirement for a user's account.
This also handles the top level error handling, and permissions.
"""
try:
retirement_id = request.data['retirement_id']
except KeyError:
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)
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}"
}
)
handle_retirement_cancellation(retirement)
return Response(data={"success": True}, status=status.HTTP_200_OK)
class AccountRetirementStatusView(ViewSet):
"""
Provides API endpoints for managing the user retirement process.
"""
authentication_classes = (JwtAuthentication,)
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
parser_classes = (JSONParser,)
serializer_class = UserRetirementStatusSerializer
def retirement_queue(self, request):
"""
GET /api/user/v1/accounts/retirement_queue/
{'cool_off_days': 7, 'states': ['PENDING', 'COMPLETE'], 'limit': 500}
Returns the list of RetirementStatus users in the given states that were
created in the retirement queue at least `cool_off_days` ago.
"""
try:
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.')
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}')
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
)
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'
)
if limit:
retirements = retirements[:limit_count]
serializer = UserRetirementStatusSerializer(retirements, many=True)
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)
except KeyError as exc:
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)
def retirements_by_status_and_date(self, request):
"""
GET /api/user/v1/accounts/retirements_by_status_and_date/
?start_date=2018-09-05&end_date=2018-09-07&state=COMPLETE
Returns a list of UserRetirementStatusSerializer serialized
RetirementStatus rows in the given state that were created in the
retirement queue between the dates given. Date range is inclusive,
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)
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.')
# 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_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'