diff --git a/cms/envs/common.py b/cms/envs/common.py index af2236b219559d74deb83e877fe0c407f361a457..3b82d90008f579ba0dcf99e8191df3bd1f5b7a26 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -966,6 +966,7 @@ INSTALLED_APPS = [ # Standard apps 'django.contrib.auth', 'django.contrib.contenttypes', + 'django.contrib.humanize', 'django.contrib.redirects', 'django.contrib.sessions', 'django.contrib.sites', @@ -980,6 +981,9 @@ INSTALLED_APPS = [ # Common views 'openedx.core.djangoapps.common_views', + # API access administration + 'openedx.core.djangoapps.api_admin', + # History tables 'simple_history', @@ -1045,7 +1049,13 @@ INSTALLED_APPS = [ # Dark-launching languages 'openedx.core.djangoapps.dark_lang', + # # User preferences + 'wiki', + 'django_notify', + 'course_wiki', # Our customizations + 'mptt', + 'sekizai', 'openedx.core.djangoapps.user_api', 'django_openid_auth', diff --git a/cms/envs/test.py b/cms/envs/test.py index 0542a2cc2e6a01a04be36522bf2e042fee401a7e..6a1e05f7ceab1897c5c1f5c02ad3b89309b53a53 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -317,9 +317,6 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' INSTALLED_APPS.append('openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig') FEATURES['CUSTOM_COURSES_EDX'] = True -# API access management -- needed for simple-history to run. -INSTALLED_APPS.append('openedx.core.djangoapps.api_admin') - ########################## VIDEO IMAGE STORAGE ############################ VIDEO_IMAGE_SETTINGS = dict( VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index fca7eaf31f446d346a7683b2ce69283173dcd487..16c2d98c366f8a319897b58c924874832e7e9fa6 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -12,6 +12,7 @@ from consent.models import DataSharingConsent import ddt from django.conf import settings from django.contrib.auth.models import User +from django.contrib.sites.models import Site from django.core.cache import cache from django.core.urlresolvers import reverse from django.test import TestCase @@ -35,10 +36,18 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase from six import text_type from social_django.models import UserSocialAuth +from wiki.models import ArticleRevision, Article +from wiki.models.pluginbase import RevisionPluginRevision, RevisionPlugin +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from entitlements.models import CourseEntitlementSupportDetail from entitlements.tests.factories import CourseEntitlementFactory from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.credit.models import ( + CreditRequirementStatus, CreditRequest, CreditCourse, CreditProvider, CreditRequirement +) from openedx.core.djangoapps.course_groups.models import CourseUserGroup, UnregisteredLearnerCohortAssignments from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY @@ -47,9 +56,14 @@ from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirem from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.token_utils import JwtBuilder +from survey.models import SurveyAnswer from student.models import ( + CourseEnrollment, CourseEnrollmentAllowed, + ManualEnrollmentAudit, + PasswordHistory, PendingEmailChange, + PendingNameChange, Registration, SocialLink, UserProfile, @@ -1918,3 +1932,124 @@ class TestAccountRetirementPost(RetirementTestCase): self.assertEqual(self.test_user, self.photo_verification.user) for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'): self.assertEqual('', getattr(self.photo_verification, field)) + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS') +class TestLMSAccountRetirementPost(RetirementTestCase, ModuleStoreTestCase): + """ + Tests the LMS account retirement (GDPR P2) endpoint. + """ + def setUp(self): + super(TestLMSAccountRetirementPost, self).setUp() + self.pii_standin = 'PII here' + self.course = CourseFactory() + self.test_user = UserFactory() + self.test_superuser = SuperuserFactory() + self.original_username = self.test_user.username + self.original_email = self.test_user.email + self.retired_username = get_retired_username_by_username(self.original_username) + self.retired_email = get_retired_email_by_email(self.original_email) + + retirement_state = RetirementState.objects.get(state_name='RETIRING_LMS') + self.retirement_status = UserRetirementStatus.create_retirement(self.test_user) + self.retirement_status.current_state = retirement_state + self.retirement_status.last_state = retirement_state + self.retirement_status.save() + + # wiki data setup + rp = RevisionPlugin.objects.create(article_id=0) + RevisionPluginRevision.objects.create( + revision_number=1, + ip_address="ipaddresss", + plugin=rp, + user=self.test_user, + ) + article = Article.objects.create() + ArticleRevision.objects.create(ip_address="ipaddresss", user=self.test_user, article=article) + + # ManualEnrollmentAudit setup + course_enrollment = CourseEnrollment.enroll(user=self.test_user, course_key=self.course.id) + ManualEnrollmentAudit.objects.create( + enrollment=course_enrollment, reason=self.pii_standin, enrolled_email=self.pii_standin + ) + + # CreditRequest and CreditRequirementStatus setup + provider = CreditProvider.objects.create(provider_id="Hogwarts") + credit_course = CreditCourse.objects.create(course_key=self.course.id) + CreditRequest.objects.create( + username=self.test_user.username, + course=credit_course, + provider_id=provider.id, + parameters={self.pii_standin}, + ) + req = CreditRequirement.objects.create(course_id=credit_course.id) + CreditRequirementStatus.objects.create(username=self.test_user.username, requirement=req) + + # ApiAccessRequest setup + site = Site.objects.create() + ApiAccessRequest.objects.create( + user=self.test_user, + site=site, + website=self.pii_standin, + company_address=self.pii_standin, + company_name=self.pii_standin, + reason=self.pii_standin, + ) + + # SurveyAnswer setup + SurveyAnswer.objects.create(user=self.test_user, field_value=self.pii_standin, form_id=0) + + # other setup + PendingNameChange.objects.create(user=self.test_user, new_name=self.pii_standin, rationale=self.pii_standin) + PasswordHistory.objects.create(user=self.test_user, password=self.pii_standin) + + # setup for doing POST from test client + self.headers = self.build_jwt_headers(self.test_superuser) + self.headers['content_type'] = "application/json" + self.url = reverse('accounts_retire_misc') + + def post_and_assert_status(self, data, expected_status=status.HTTP_204_NO_CONTENT): + """ + Helper function for making a request to the retire subscriptions endpoint, and asserting the status. + """ + response = self.client.post(self.url, json.dumps(data), **self.headers) + self.assertEqual(response.status_code, expected_status) + return response + + def test_retire_user(self): + # check that rows that will not exist after retirement exist now + self.assertTrue(CreditRequest.objects.filter(username=self.test_user.username).exists()) + self.assertTrue(CreditRequirementStatus.objects.filter(username=self.test_user.username).exists()) + self.assertTrue(PendingNameChange.objects.filter(user=self.test_user).exists()) + + retirement = UserRetirementStatus.get_retirement_for_retirement_action(self.test_user.username) + data = {'username': self.original_username} + self.post_and_assert_status(data) + + self.test_user.refresh_from_db() + self.test_user.profile.refresh_from_db() # pylint: disable=no-member + self.assertEqual(RevisionPluginRevision.objects.get(user=self.test_user).ip_address, None) + self.assertEqual(ArticleRevision.objects.get(user=self.test_user).ip_address, None) + self.assertFalse(PendingNameChange.objects.filter(user=self.test_user).exists()) + self.assertEqual(PasswordHistory.objects.get(user=self.test_user).password, '') + + self.assertEqual( + ManualEnrollmentAudit.objects.get( + enrollment=CourseEnrollment.objects.get(user=self.test_user) + ).enrolled_email, + retirement.retired_email + ) + self.assertFalse(CreditRequest.objects.filter(username=self.test_user.username).exists()) + self.assertTrue(CreditRequest.objects.filter(username=retirement.retired_username).exists()) + self.assertEqual(CreditRequest.objects.get(username=retirement.retired_username).parameters, {}) + + self.assertFalse(CreditRequirementStatus.objects.filter(username=self.test_user.username).exists()) + self.assertTrue(CreditRequirementStatus.objects.filter(username=retirement.retired_username).exists()) + self.assertEqual(CreditRequirementStatus.objects.get(username=retirement.retired_username).reason, {}) + + retired_api_access_request = ApiAccessRequest.objects.get(user=self.test_user) + self.assertEqual(retired_api_access_request.website, '') + self.assertEqual(retired_api_access_request.company_address, '') + self.assertEqual(retired_api_access_request.company_name, '') + self.assertEqual(retired_api_access_request.reason, '') + self.assertEqual(SurveyAnswer.objects.get(user=self.test_user).field_value, '') diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index b87bf27527966aaa9376de11e3b81762c3573804..e2404481dc38f12a4be082b5f7fb562303dc2bf4 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -16,6 +16,7 @@ from django.db import transaction from django.utils.translation import ugettext as _ from edx_rest_framework_extensions.authentication import JwtAuthentication 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 @@ -25,20 +26,29 @@ from rest_framework.views import APIView from rest_framework.viewsets import ViewSet from six import text_type from social_django.models import UserSocialAuth +from wiki.models import ArticleRevision +from wiki.models.pluginbase import RevisionPluginRevision from entitlements.models import CourseEntitlement from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest +from openedx.core.djangoapps.credit.models import CreditRequirementStatus, CreditRequest from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments from openedx.core.djangoapps.profile_images.images import remove_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in -from openedx.core.djangolib.oauth2_retirement_utils import retire_dop_oauth2_models, retire_dot_oauth2_models +from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models, retire_dop_oauth2_models from openedx.core.lib.api.authentication import ( OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser ) from openedx.core.lib.api.parsers import MergePatchParser +from survey.models import SurveyAnswer from student.models import ( + CourseEnrollment, + ManualEnrollmentAudit, + PasswordHistory, + PendingNameChange, CourseEnrollmentAllowed, PendingEmailChange, Registration, @@ -581,6 +591,54 @@ class AccountRetirementStatusView(ViewSet): return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) +class LMSAccountRetirementView(ViewSet): + """ + Provides an API endpoint for retiring a user in the LMS. + """ + authentication_classes = (JwtAuthentication,) + permission_classes = (permissions.IsAuthenticated, CanRetireUser,) + parser_classes = (JSONParser,) + + @request_requires_username + def post(self, request): + """ + POST /api/user/v1/accounts/retire_misc/ + + { + 'username': 'user_to_retire' + } + + Retires the user with the given username in the LMS. + """ + + username = request.data['username'] + if is_username_retired(username): + return Response(status=status.HTTP_404_NOT_FOUND) + + 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') + PasswordHistory.retire_user(retirement.user.id) + course_enrollments = CourseEnrollment.objects.filter(user=retirement.user) + ManualEnrollmentAudit.retire_manual_enrollments(course_enrollments, retirement.retired_email) + + CreditRequest.retire_user(retirement.original_username, retirement.retired_username) + ApiAccessRequest.retire_user(retirement.user) + CreditRequirementStatus.retire_user(retirement.user.username) + SurveyAnswer.retire_user(retirement.user.id) + + except UserRetirementStatus.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except RetirementStateError as exc: + return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: # pylint: disable=broad-except + return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response(status=status.HTTP_204_NO_CONTENT) + + class AccountRetirementView(ViewSet): """ Provides API endpoint for retiring a user. @@ -621,6 +679,7 @@ class AccountRetirementView(ViewSet): # Retire data from Enterprise models self.retire_users_data_sharing_consent(username, retired_username) self.retire_sapsf_data_transmission(user) + self.retire_degreed_data_transmission(user) self.retire_user_from_pending_enterprise_customer_user(user, retired_email) self.retire_entitlement_support_detail(user) @@ -688,6 +747,17 @@ class AccountRetirementView(ViewSet): ) audits.update(sapsf_user_id='') + @staticmethod + def retire_degreed_data_transmission(user): + for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id): + 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='') + @staticmethod def retire_user_from_pending_enterprise_customer_user(user, retired_email): PendingEnterpriseCustomerUser.objects.filter(user_email=user.email).update(user_email=retired_email) diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index d2bcb29fca1c256ead1eeee94c9771fb5d3816a4..61086021ab62460bfed5a95dcec384bf61739800 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -12,7 +12,8 @@ from .accounts.views import ( AccountRetirementStatusView, AccountRetirementView, AccountViewSet, - DeactivateLogoutView + DeactivateLogoutView, + LMSAccountRetirementView ) from .preferences.views import PreferencesDetailView, PreferencesView from .verification_api.views import IDVerificationStatusView @@ -47,6 +48,9 @@ RETIREMENT_POST = AccountRetirementView.as_view({ 'post': 'post', }) +RETIREMENT_LMS_POST = LMSAccountRetirementView.as_view({ + 'post': 'post', +}) urlpatterns = [ url( @@ -104,6 +108,11 @@ urlpatterns = [ RETIREMENT_POST, name='accounts_retire' ), + url( + r'^v1/accounts/retire_misc/$', + RETIREMENT_LMS_POST, + name='accounts_retire_misc' + ), url( r'^v1/accounts/update_retirement_status/$', RETIREMENT_UPDATE,