Skip to content
Snippets Groups Projects
Commit 8fbe12e4 authored by bmedx's avatar bmedx
Browse files

Add retirement partner reporting queue and APIs

parent 6600e8b7
No related branches found
No related tags found
No related merge requests found
......@@ -14,7 +14,11 @@ from six import text_type
from lms.djangoapps.badges.utils import badges_enabled
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import errors
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus, UserPreference
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserPreference,
UserRetirementStatus
)
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency, SocialLink
......@@ -209,7 +213,9 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
return new_name
def validate_language_proficiencies(self, value):
""" Enforce all languages are unique. """
"""
Enforce all languages are unique.
"""
language_proficiencies = [language for language in value]
unique_language_proficiencies = set(language["code"] for language in language_proficiencies)
if len(language_proficiencies) != len(unique_language_proficiencies):
......@@ -217,7 +223,9 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
return value
def validate_social_links(self, value):
""" Enforce only one entry for a particular social platform. """
"""
Enforce only one entry for a particular social platform.
"""
social_links = [social_link for social_link in value]
unique_social_links = set(social_link["platform"] for social_link in social_links)
if len(social_links) != len(unique_social_links):
......@@ -225,29 +233,41 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
return value
def transform_gender(self, user_profile, value): # pylint: disable=unused-argument
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
"""
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
"""
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
def transform_country(self, user_profile, value): # pylint: disable=unused-argument
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
"""
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
"""
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
def transform_level_of_education(self, user_profile, value): # pylint: disable=unused-argument
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
"""
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
"""
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
def transform_bio(self, user_profile, value): # pylint: disable=unused-argument
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
"""
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
"""
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
@staticmethod
def convert_empty_to_None(value):
""" Helper method to convert empty string to None (other values pass through). """
"""
Helper method to convert empty string to None (other values pass through).
"""
return None if value == "" else value
@staticmethod
def get_profile_image(user_profile, user, request=None):
""" Returns metadata about a user's profile image. """
"""
Returns metadata about a user's profile image.
"""
data = {'has_image': user_profile.has_profile_image}
urls = get_profile_image_urls_for_user(user, request)
data.update({
......@@ -257,7 +277,9 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
return data
def get_requires_parental_consent(self, user_profile):
""" Returns a boolean representing whether the user requires parental controls. """
"""
Returns a boolean representing whether the user requires parental controls.
"""
return user_profile.requires_parental_consent()
def _get_profile_image(self, user_profile):
......@@ -374,8 +396,27 @@ class UserRetirementStatusSerializer(serializers.ModelSerializer):
exclude = ['responses', ]
class UserRetirementPartnerReportSerializer(serializers.Serializer):
"""
Perform serialization for the UserRetirementPartnerReportingStatus model
"""
original_username = serializers.CharField()
original_email = serializers.EmailField()
original_name = serializers.CharField()
orgs = serializers.ListField(child=serializers.CharField())
# Required overrides of abstract base class methods, but we don't use them
def create(self, validated_data):
pass
def update(self, instance, validated_data):
pass
def get_extended_profile(user_profile):
"""Returns the extended user profile fields stored in user_profile.meta"""
"""
Returns the extended user profile fields stored in user_profile.meta
"""
# pick the keys from the site configuration
extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
......@@ -395,7 +436,9 @@ def get_extended_profile(user_profile):
def get_profile_visibility(user_profile, user, configuration=None):
"""Returns the visibility level for the specified user profile."""
"""
Returns the visibility level for the specified user profile.
"""
if user_profile.requires_parental_consent():
return PRIVATE_VISIBILITY
......
This diff is collapsed.
......@@ -66,10 +66,16 @@ from student.models import (
from student.views.login import AuthFailedError, LoginFailures
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
from ..models import RetirementState, RetirementStateError, UserOrgTag, UserRetirementStatus
from ..models import (
RetirementState,
RetirementStateError,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus
)
from .api import get_account_settings, update_account_settings
from .permissions import CanDeactivateUser, CanRetireUser
from .serializers import UserRetirementStatusSerializer
from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer
from .signals import USER_RETIRE_MAILINGS
from ..message_types import DeletionNotificationMessage
......@@ -515,6 +521,87 @@ def _set_unusable_password(user):
user.save()
class AccountRetirementPartnerReportView(ViewSet):
"""
Provides API endpoints for managing partner reporting of retired
users.
"""
authentication_classes = (JwtAuthentication,)
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
parser_classes = (JSONParser,)
serializer_class = UserRetirementStatusSerializer
def _get_orgs_for_user(self, user):
"""
Returns a set of orgs that the user has enrollments with
"""
orgs = set()
for enrollment in user.courseenrollment_set.all():
org = enrollment.course.org
# Org can concievably be blank or this bogus default value
if org and org != 'outdated_entry':
orgs.add(enrollment.course.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 = [
{
'original_username': retirement.original_username,
'original_email': retirement.original_email,
'original_name': retirement.original_name,
'orgs': self._get_orgs_for_user(retirement.user)
}
for retirement in retirement_statuses
]
serializer = UserRetirementPartnerReportSerializer(retirements, many=True)
retirement_statuses.update(is_being_processed=True)
return Response(serializer.data)
def retirement_partner_cleanup(self, request):
"""
DELETE /api/user/v1/accounts/retirement_partner_report/
[{'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
)
if len(usernames) != len(retirement_statuses):
return Response(
'{} original_usernames given, only {} found!'.format(len(usernames), len(retirement_statuses)),
status=status.HTTP_400_BAD_REQUEST
)
retirement_statuses.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class AccountRetirementStatusView(ViewSet):
"""
Provides API endpoints for managing the user retirement process.
......
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-13 20:54
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('user_api', '0003_userretirementrequest'),
]
operations = [
migrations.CreateModel(
name='UserRetirementPartnerReportingStatus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('original_username', models.CharField(db_index=True, max_length=150)),
('original_email', models.EmailField(db_index=True, max_length=254)),
('original_name', models.CharField(blank=True, db_index=True, max_length=255)),
('is_being_processed', models.BooleanField(default=False)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Retirement Reporting Status',
'verbose_name_plural': 'User Retirement Reporting Statuses',
},
),
]
......@@ -167,6 +167,31 @@ class RetirementState(models.Model):
return cls.objects.all().values_list('state_name', flat=True)
class UserRetirementPartnerReportingStatus(TimeStampedModel):
"""
When a user has been retired from LMS it will still need to be reported out to
partners so they can forget the user also. This process happens on a very different,
and asynchronous, timeline than LMS retirement and only impacts a subset of learners
so it maintains a queue. This queue is populated as part of the LMS retirement
process.
"""
user = models.OneToOneField(User)
original_username = models.CharField(max_length=150, db_index=True)
original_email = models.EmailField(db_index=True)
original_name = models.CharField(max_length=255, blank=True, db_index=True)
is_being_processed = models.BooleanField(default=False)
class Meta(object):
verbose_name = 'User Retirement Reporting Status'
verbose_name_plural = 'User Retirement Reporting Statuses'
def __unicode__(self):
return u'UserRetirementPartnerReportingStatus: {} is being processed: {}'.format(
self.user,
self.is_being_processed
)
class UserRetirementRequest(TimeStampedModel):
"""
Records and perists every user retirement request.
......
......@@ -20,7 +20,7 @@ from six import text_type
from social_django.models import UserSocialAuth, Partial
from django_comment_common import models
from openedx.core.djangoapps.user_api.accounts.tests.test_views import RetirementTestCase
from openedx.core.djangoapps.user_api.accounts.tests.test_retirement_views import RetirementTestCase
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
from openedx.core.djangoapps.site_configuration.helpers import get_value
from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY
......
......@@ -9,6 +9,7 @@ from ..profile_images.views import ProfileImageView
from .accounts.views import (
AccountDeactivationView,
AccountRetireMailingsView,
AccountRetirementPartnerReportView,
AccountRetirementStatusView,
AccountRetirementView,
AccountViewSet,
......@@ -32,6 +33,11 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({
'patch': 'partial_update',
})
PARTNER_REPORT = AccountRetirementPartnerReportView.as_view({
'post': 'retirement_partner_report',
'delete': 'retirement_partner_cleanup'
})
RETIREMENT_QUEUE = AccountRetirementStatusView.as_view({
'get': 'retirement_queue'
})
......@@ -98,6 +104,11 @@ urlpatterns = [
RETIREMENT_RETRIEVE,
name='accounts_retirement_retrieve'
),
url(
r'^v1/accounts/retirement_partner_report/$',
PARTNER_REPORT,
name='accounts_retirement_partner_report'
),
url(
r'^v1/accounts/retirement_queue/$',
RETIREMENT_QUEUE,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment