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'/>