From 4db9a6aa8d7735cb58c83862d11e89847df22366 Mon Sep 17 00:00:00 2001 From: "Albert St. Aubin" Date: Tue, 21 Jul 2020 15:34:32 -0400 Subject: [PATCH] [MICROBA-485] Added support API and model for hiding the demographics CTA The Demographics CTA is used to ask users to respond to the optional questions. This API and model support the API being dismissed by the user so they are not bothered by it. --- openedx/core/djangoapps/demographics/admin.py | 21 +++++++ .../djangoapps/demographics/api/status.py | 12 ++++ .../demographics/migrations/0001_initial.py | 55 +++++++++++++++++++ .../demographics/migrations/__init__.py | 0 .../core/djangoapps/demographics/models.py | 24 ++++++++ .../demographics/rest_api/v1/urls.py | 1 + .../demographics/rest_api/v1/views.py | 42 +++++++++++--- .../demographics/tests/factories.py | 16 ++++++ .../demographics/tests/test_status.py | 33 ++++++++++- .../lms/static/js/demographics-collection.js | 23 ++++++++ .../sass/partials/lms/theme/_dashboard.scss | 37 ++++++++++++- themes/edx.org/lms/templates/dashboard.html | 34 +++++++++--- 12 files changed, 280 insertions(+), 18 deletions(-) create mode 100644 openedx/core/djangoapps/demographics/admin.py create mode 100644 openedx/core/djangoapps/demographics/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/demographics/migrations/__init__.py create mode 100644 openedx/core/djangoapps/demographics/models.py create mode 100644 openedx/core/djangoapps/demographics/tests/factories.py create mode 100644 themes/edx.org/lms/static/js/demographics-collection.js diff --git a/openedx/core/djangoapps/demographics/admin.py b/openedx/core/djangoapps/demographics/admin.py new file mode 100644 index 0000000000..0f89139ce9 --- /dev/null +++ b/openedx/core/djangoapps/demographics/admin.py @@ -0,0 +1,21 @@ +""" +Django admin page for demographics +""" + +from django.contrib import admin + +from openedx.core.djangoapps.demographics.models import UserDemographics + + +class UserDemographicsAdmin(admin.ModelAdmin): + """ + Admin for UserDemographics Model + """ + list_display = ('user', 'show_call_to_action') + readonly_fields = ('user',) + + class Meta(object): + model = UserDemographics + + +admin.site.register(UserDemographics, UserDemographicsAdmin) diff --git a/openedx/core/djangoapps/demographics/api/status.py b/openedx/core/djangoapps/demographics/api/status.py index 520498af5c..1ed47340c7 100644 --- a/openedx/core/djangoapps/demographics/api/status.py +++ b/openedx/core/djangoapps/demographics/api/status.py @@ -4,6 +4,7 @@ Python API for Demographics Status from openedx.features.enterprise_support.utils import is_enterprise_learner from openedx.core.djangoapps.programs.utils import is_user_enrolled_in_program_type +from openedx.core.djangoapps.demographics.models import UserDemographics def show_user_demographics(user, enrollments=None, entitlements=None): @@ -18,3 +19,14 @@ def show_user_demographics(user, enrollments=None, entitlements=None): entitlements=entitlements ) return is_user_in_microbachelors_program and not is_enterprise_learner(user) + + +def show_call_to_action_for_user(user): + """ + Utility method to determine if a user should be shown the Demographics call to + action. + """ + try: + return UserDemographics.objects.get(user=user).show_call_to_action + except UserDemographics.DoesNotExist: + return True diff --git a/openedx/core/djangoapps/demographics/migrations/0001_initial.py b/openedx/core/djangoapps/demographics/migrations/0001_initial.py new file mode 100644 index 0000000000..3134061125 --- /dev/null +++ b/openedx/core/djangoapps/demographics/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 2.2.14 on 2020-07-23 19:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserDemographics', + 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')), + ('show_call_to_action', models.BooleanField(default=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'user demographic', + 'verbose_name_plural': 'user demographic', + }, + ), + migrations.CreateModel( + name='HistoricalUserDemographics', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, 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')), + ('show_call_to_action', models.BooleanField(default=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical user demographic', + 'get_latest_by': 'history_date', + 'ordering': ('-history_date', '-history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/openedx/core/djangoapps/demographics/migrations/__init__.py b/openedx/core/djangoapps/demographics/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/demographics/models.py b/openedx/core/djangoapps/demographics/models.py new file mode 100644 index 0000000000..be8251c80c --- /dev/null +++ b/openedx/core/djangoapps/demographics/models.py @@ -0,0 +1,24 @@ +from django.contrib.auth import get_user_model +from django.db import models +from model_utils.models import TimeStampedModel +from simple_history.models import HistoricalRecords + +User = get_user_model() + + +class UserDemographics(TimeStampedModel): + """ + A Users Demographics platform related data in support of the Demographics + IDA and features + """ + user = models.ForeignKey(User, on_delete=models.CASCADE) + show_call_to_action = models.BooleanField(default=True) + history = HistoricalRecords(app='demographics') + + class Meta(object): + app_label = "demographics" + verbose_name = "user demographic" + verbose_name_plural = "user demographic" + + def __str__(self): + return 'UserDemographics for {}'.format(self.user) diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/urls.py b/openedx/core/djangoapps/demographics/rest_api/v1/urls.py index 881a76f379..389ce5e791 100644 --- a/openedx/core/djangoapps/demographics/rest_api/v1/urls.py +++ b/openedx/core/djangoapps/demographics/rest_api/v1/urls.py @@ -4,6 +4,7 @@ URL Routes for this app. from django.conf.urls import url from .views import DemographicsStatusView + urlpatterns = [ url( r'^demographics/status/$', diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/views.py b/openedx/core/djangoapps/demographics/rest_api/v1/views.py index 4f2faee03b..6d666f8bd8 100644 --- a/openedx/core/djangoapps/demographics/rest_api/v1/views.py +++ b/openedx/core/djangoapps/demographics/rest_api/v1/views.py @@ -1,10 +1,14 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework.views import APIView -from rest_framework import permissions +from rest_framework import permissions, status from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response -from openedx.core.djangoapps.demographics.api.status import show_user_demographics +from rest_framework.views import APIView + +from openedx.core.djangoapps.demographics.api.status import ( + show_user_demographics, show_call_to_action_for_user, +) +from openedx.core.djangoapps.demographics.models import UserDemographics class DemographicsStatusView(APIView): @@ -17,12 +21,36 @@ class DemographicsStatusView(APIView): authentication_classes = (JwtAuthentication, SessionAuthentication) permission_classes = (permissions.IsAuthenticated, ) + def _response_context(self, user, user_demographics=None): + if user_demographics: + show_call_to_action = user_demographics.show_call_to_action + else: + show_call_to_action = show_call_to_action_for_user(user) + return { + 'display': show_user_demographics(user), + 'show_call_to_action': show_call_to_action + } + def get(self, request): """ - GET /api/user/v1/accounts/demographics_status + GET /api/user/v1/accounts/demographics/status - This is a Web API to determine whether or not we should show Demographics to a learner - based on their enrollment status. + This is a Web API to determine the status of demographics related features """ user = request.user - return Response({'display': show_user_demographics(user)}) + return Response(self._response_context(user)) + + def patch(self, request): + """ + PATCH /api/user/v1/accounts/demographics/status + + This is a Web API to update fields that are dependent on user interaction. + """ + show_call_to_action = request.data.get('show_call_to_action') + user = request.user + if not isinstance(show_call_to_action, bool): + return Response(status.HTTP_400_BAD_REQUEST) + (user_demographics, _) = UserDemographics.objects.get_or_create(user=user) + user_demographics.show_call_to_action = show_call_to_action + user_demographics.save() + return Response(self._response_context(user, user_demographics)) diff --git a/openedx/core/djangoapps/demographics/tests/factories.py b/openedx/core/djangoapps/demographics/tests/factories.py new file mode 100644 index 0000000000..ddfdcf7698 --- /dev/null +++ b/openedx/core/djangoapps/demographics/tests/factories.py @@ -0,0 +1,16 @@ +""" +Factoryboy factories for Demographics. +""" + +import factory + +from openedx.core.djangoapps.demographics.models import UserDemographics + + +class UserDemographicsFactory(factory.django.DjangoModelFactory): + """ + UserDemographics Factory + """ + + class Meta(object): + model = UserDemographics diff --git a/openedx/core/djangoapps/demographics/tests/test_status.py b/openedx/core/djangoapps/demographics/tests/test_status.py index f91bba0b8b..a8df58793c 100644 --- a/openedx/core/djangoapps/demographics/tests/test_status.py +++ b/openedx/core/djangoapps/demographics/tests/test_status.py @@ -2,6 +2,11 @@ Test status utilities """ import mock + +from django.conf import settings +from pytest import mark +from unittest import TestCase + from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory from opaque_keys.edx.keys import CourseKey @@ -13,10 +18,13 @@ from xmodule.modulestore.tests.factories import CourseFactory from openedx.core.djangoapps.catalog.tests.factories import ( ProgramFactory, ) -from openedx.core.djangoapps.demographics.api.status import show_user_demographics from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerUserFactory from openedx.core.djangolib.testing.utils import skip_unless_lms +if settings.ROOT_URLCONF == 'lms.urls': + from openedx.core.djangoapps.demographics.api.status import show_user_demographics, show_call_to_action_for_user + from openedx.core.djangoapps.demographics.tests.factories import UserDemographicsFactory + MICROBACHELORS = 'microbachelors' @@ -45,3 +53,26 @@ class TestShowDemographics(SharedModuleStoreTestCase): mock_get_programs_by_type.return_value = [self.program] EnterpriseCustomerUserFactory.create(user_id=self.user.id) self.assertFalse(show_user_demographics(user=self.user)) + + +@skip_unless_lms +@mark.django_db +class TestShowCallToAction(TestCase): + def setUp(self): + super(TestShowCallToAction, self).setUp() + self.user = UserFactory() + + def test_new_user(self): + self.assertTrue(show_call_to_action_for_user(self.user)) + + def test_existing_user_no_dismiss(self): + user_demographics = UserDemographicsFactory.create(user=self.user) + self.assertTrue(user_demographics.show_call_to_action) + self.assertTrue(show_call_to_action_for_user(self.user)) + + def test_existing_user_dismissed(self): + user_demographics = UserDemographicsFactory.create(user=self.user) + user_demographics.show_call_to_action = False + user_demographics.save() + self.assertFalse(user_demographics.show_call_to_action) + self.assertFalse(show_call_to_action_for_user(self.user)) diff --git a/themes/edx.org/lms/static/js/demographics-collection.js b/themes/edx.org/lms/static/js/demographics-collection.js new file mode 100644 index 0000000000..eabc3bc8a2 --- /dev/null +++ b/themes/edx.org/lms/static/js/demographics-collection.js @@ -0,0 +1,23 @@ +$(document).ready(function() { + 'use strict'; + + $('#demographics-dismiss').click(function(event) { + event.preventDefault(); + return $.ajax({ + url: '/api/demographics/v1/demographics/status/', + type: 'PATCH', + headers: { + 'X-CSRFToken': $.cookie('csrftoken') + }, + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify({ + show_call_to_action: false + }), + context: this, + success: function() { + $('#demographics-banner-link').hide(); + } + }); + }); +}); diff --git a/themes/edx.org/lms/static/sass/partials/lms/theme/_dashboard.scss b/themes/edx.org/lms/static/sass/partials/lms/theme/_dashboard.scss index 5cc422202d..9e6e2d0a37 100644 --- a/themes/edx.org/lms/static/sass/partials/lms/theme/_dashboard.scss +++ b/themes/edx.org/lms/static/sass/partials/lms/theme/_dashboard.scss @@ -5,18 +5,52 @@ @media (min-width: 1200px) { height: 64px; } + + .btn-circle { + width: 40px; + height: 40px; + border-radius: 15px; + text-align: center; + font-size: 24px; + line-height: 1.42857; + } + + .demographics-dismiss-container { + margin-left: -40px; + + .demographics-dismiss-btn { + font-size: 20px + } + .demographics-dismiss-btn:hover { + background-image: none; + background-color: #23419f; + border: none; + } + } + .demographics-banner-icon { height: 140px; } + .demographics-banner-prompt { font-size: 24px; line-height: 24px; + width: 85%; + + @media (min-width: 1200px) { + width: unset; + } } + .demographics-banner-btn { color: #23419f; border-radius: 20px; font-size: 14px; - min-width: 150px; + + @media (min-width: 1200px) { + min-width: 150px; + } + /* Below are to override the overly-broad `button` selectors in lms/static/sass/shared/_forms.scss */ box-shadow: unset; text-shadow: unset; @@ -29,6 +63,7 @@ } } } + .side-container { .wrapper-coaching { border: 1px solid $gray-500; diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index 0f0376f6c5..6c17bdc91c 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -44,6 +44,7 @@ from student.models import CourseEnrollment <%block name="js_extra"> + <%static:js group='dashboard'/>