[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.
This commit is contained in:
Albert St. Aubin
2020-07-21 15:34:32 -04:00
committed by Albert (AJ) St. Aubin
parent 2ab5eb5dad
commit 4db9a6aa8d
12 changed files with 280 additions and 18 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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)

View File

@@ -4,6 +4,7 @@ URL Routes for this app.
from django.conf.urls import url
from .views import DemographicsStatusView
urlpatterns = [
url(
r'^demographics/status/$',

View File

@@ -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))

View File

@@ -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

View File

@@ -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))

View File

@@ -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();
}
});
});
});

View File

@@ -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;

View File

@@ -44,6 +44,7 @@ from student.models import CourseEnrollment
<%block name="js_extra">
<script src="${static.url('js/commerce/credit.js')}"></script>
<script src="${static.url('js/demographics-collection.js')}"></script>
<%static:js group='dashboard'/>
<script type="text/javascript">
$(document).ready(function() {
@@ -141,29 +142,44 @@ from student.models import CourseEnrollment
<section class="dashboard" id="dashboard-main">
<main class="main-container" id="main" aria-label="Content" tabindex="-1">
<%
from openedx.core.djangoapps.demographics.api.status import show_user_demographics
from openedx.core.djangoapps.demographics.api.status import (
show_user_demographics, show_call_to_action_for_user
)
from openedx.core.djangoapps.enrollments.api import serialize_enrollments
serialized_course_enrollments = serialize_enrollments(course_enrollments)
demographics_enabled = getattr(settings, 'ENABLE_DEMOGRAPHICS_COLLECTION', False)
display_demographics_banner = demographics_enabled and show_user_demographics(
user, enrollments=serialized_course_enrollments, entitlements=course_entitlements
display_demographics_banner = (
demographics_enabled and
show_user_demographics(user, enrollments=serialized_course_enrollments, entitlements=course_entitlements) and
show_call_to_action_for_user(user)
)
account_mfe_url = getattr(settings, 'ACCOUNT_MICROFRONTEND_URL', '') or ''
%>
% if display_demographics_banner:
<a class="btn" href="${account_mfe_url}#demographics-information">
<div
class="demographics-banner d-flex justify-content-left align-items-center flex-column flex-lg-row py-1 px-4 mb-2 mb-lg-4">
<a id="demographics-banner-link" class="btn" href="${account_mfe_url}#demographics-information">
<div
class="demographics-banner d-flex justify-content-lg-between flex-row py-1 px-2 mb-2 mb-lg-4"
role="dialog"
aria-modal="false"
aria-label="demographics questionnaire pitch"
>
<div class="d-flex justify-content-left align-items-lg-center flex-column flex-lg-row w-100">
<img class="demographics-banner-icon d-none d-lg-inline-block" src="${static.url('edx.org/images/quote_prompt.png')}" alt="" aria-hidden="true">
<div class="demographics-banner-prompt d-inline-block font-weight-bold text-white py-4 px-2 px-lg-3">
<div class="demographics-banner-prompt d-inline-block font-weight-bold text-white mr-4 py-3 px-2 px-lg-3">
${_("Want to make edX better for everyone?")}
</div>
<button class="demographics-banner-btn d-flex align-items-center bg-white font-weight-bold border-0 py-2 px-3 mx-2 mb-3 m-lg-0 shadow">
<button class="demographics-banner-btn d-flex align-items-center bg-white font-weight-bold border-0 py-2 px-3 mx-2 mb-3 m-lg-0 shadow justify-content-center">
<span class="fa fa-thumbs-up px-2" aria-hidden="true"></span>
${_("Get started")}
</button>
</div>
</a>
<div class="demographics-dismiss-container md-flex justify-content-right align-self-start align-self-lg-center ml-lg-auto">
<button type="button" class="demographics-dismiss-btn btn btn-default px-0" id="demographics-dismiss" aria-label="close">
<i class="fa fa-times-circle text-white px-2" aria-hidden="true"></i>
</button>
</div>
</div>
</a>
% endif
<section class="my-courses" id="my-courses">
<header class="wrapper-header-courses">