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