feat: AA-1055: Add in User Tours to the platform

User Tours are walkthroughs we are able to give in our frontends.
This sets up the backend support for them by creating the model,
setting up the initial backfill, adds in a signal handler to init
the UserTour model on User creation, and sets up some endpoints
to get user tour information and update it. It is also being
initialized with a waffle flag to control the rollout. The flag is
intended to control all tours and not allow for opting into only some tours.
This commit is contained in:
Dillon Dumesnil
2021-11-10 12:19:45 -05:00
parent edbf01605b
commit 2f2a6317a5
25 changed files with 552 additions and 2 deletions

View File

@@ -17,7 +17,7 @@ jobs:
- module-name: lms-1
path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/"
- module-name: lms-2
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py"
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py"
- module-name: openedx-1
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/coursegraph/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/"
- module-name: openedx-2

View File

@@ -19,7 +19,7 @@ jobs:
"lms/djangoapps/courseware/",
"lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/",
"lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/",
"lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy/ lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py",
"lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy/ lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py",
"openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/coursegraph/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/",
"openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/self_paced/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/",
"cms/djangoapps/api/ cms/djangoapps/cms_user_tasks/ cms/djangoapps/course_creators/ cms/djangoapps/export_course_metadata/ cms/djangoapps/maintenance/ cms/djangoapps/models/ cms/djangoapps/pipeline_js/ cms/djangoapps/xblock_config/ cms/envs/ cms/lib/",

View File

View File

@@ -0,0 +1,13 @@
""" Django admin for User Tours. """
from django.contrib import admin
from lms.djangoapps.user_tours.models import UserTour
@admin.register(UserTour)
class UserTourAdmin(admin.ModelAdmin):
""" Admin for UserTour. """
list_display = ('user', 'course_home_tour_status', 'show_courseware_tour',)
readonly_fields = ('user',)
search_fields = ('user__username',)

View File

@@ -0,0 +1,13 @@
""" User Tour application configuration. """
from django.apps import AppConfig
class UserTourConfig(AppConfig):
""" User Tour application configuration. """
name = 'lms.djangoapps.user_tours'
def ready(self):
""" Code to run when getting the app ready. """
# Connect signal handlers.
from lms.djangoapps.user_tours import handlers # pylint: disable=unused-import

View File

@@ -0,0 +1,29 @@
""" Signal handlers for User Tours. """
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.db.utils import ProgrammingError
from django.dispatch import receiver
from lms.djangoapps.user_tours.models import UserTour
User = get_user_model()
@receiver(post_save, sender=User)
def init_user_tour(sender, instance, created, **kwargs): # pylint: disable=unused-argument
"""
Initialize a new User Tour when a new user is created.
"""
if created:
try:
UserTour.objects.create(user=instance)
# So this is here because there is a dependency issue in the migrations where
# this signal handler tries to run before the UserTour model is created.
# In reality, this should never be hit because migrations will have already run.
# If anyone better at migration dependencies is able to resolve this, please
# feel free to remove this try/except.
# The exact error we are catching is
# django.db.utils.ProgrammingError: (1146, "Table 'edxtest.user_tours_usertour' doesn't exist")
except ProgrammingError as e:
pass

View File

@@ -0,0 +1,40 @@
""" Management command to backpopulate User Tours. """
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.user_tours.models import UserTour
User = get_user_model()
class Command(BaseCommand):
"""
Example usage:
$ ./manage.py lms backpopulate_user_tours
"""
help = 'Creates or updates a row in the UserTour table for all users in the platform.'
def handle(self, *args, **options):
"""
Backpopulates UserTour objects for all existing users who don't already have a UserTour.
If the user has any prior enrollments, we will treat them as an existing user,
otherwise they will receive a new user treatment.
"""
for user in User.objects.filter(tour__isnull=True):
if CourseEnrollment.objects.filter(user=user).exists():
course_home_tour_status = UserTour.CourseHomeChoices.EXISTING_USER_TOUR
show_courseware_tour = False
else:
course_home_tour_status = UserTour.CourseHomeChoices.NEW_USER_TOUR
show_courseware_tour = True
UserTour.objects.update_or_create(
user=user,
defaults={
'course_home_tour_status': course_home_tour_status,
'show_courseware_tour': show_courseware_tour,
},
)

View File

@@ -0,0 +1,89 @@
""" Tests for backpopulate user tours Command. """
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.db.models.signals import post_save
from django.test import TestCase
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.user_tours.models import UserTour
from lms.djangoapps.user_tours.handlers import init_user_tour
from openedx.core.djangolib.testing.utils import skip_unless_lms
User = get_user_model()
@skip_unless_lms
class TestBackpopulateUserTourCommand(TestCase):
""" Tests for the backpopulate user tours Command. """
@classmethod
def setUpClass(cls):
""" Init required for the class to properly run. """
super().setUpClass()
# We need to disable the UserTour handler for the backpopulate command since it is assuming
# users without UserTours to run.
post_save.disconnect(init_user_tour, sender=User)
@classmethod
def tearDownClass(cls):
""" Tear down for the class. """
super().tearDownClass()
post_save.connect(init_user_tour, sender=User)
def test_happy_path(self):
""" Tests happy path of command with one user. """
user = UserFactory()
assert UserTour.objects.count() == 0
call_command('backpopulate_user_tours')
assert UserTour.objects.count() == 1
tour = UserTour.objects.get(user=user)
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.NEW_USER_TOUR
assert tour.show_courseware_tour
def test_mix_of_users(self):
""" Tests having new and existing users. """
new_user = UserFactory()
existing_user = UserFactory()
CourseEnrollmentFactory(user=existing_user)
assert UserTour.objects.count() == 0
call_command('backpopulate_user_tours')
assert UserTour.objects.count() == 2
new_user_tour = UserTour.objects.get(user=new_user)
assert new_user_tour.course_home_tour_status == UserTour.CourseHomeChoices.NEW_USER_TOUR
assert new_user_tour.show_courseware_tour
existing_user_tour = UserTour.objects.get(user=existing_user)
assert existing_user_tour.course_home_tour_status == UserTour.CourseHomeChoices.EXISTING_USER_TOUR
assert not existing_user_tour.show_courseware_tour
def test_rerun_of_command(self):
"""
Tests command successfully reruns if needed.
The command will ignore any user that already has a UserTour.
"""
user = UserFactory()
assert UserTour.objects.count() == 0
call_command('backpopulate_user_tours')
assert UserTour.objects.count() == 1
tour = UserTour.objects.get(user=user)
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.NEW_USER_TOUR
assert tour.show_courseware_tour
# We will update the old user to show they are untouched by a rerun because they already have a UserTour.
# These values are the opposite of what they would have from the command running.
tour.course_home_tour_status = UserTour.CourseHomeChoices.EXISTING_USER_TOUR
tour.show_courseware_tour = False
tour.save()
# Now add in a new user to show the command still works for this new user.
new_user = UserFactory()
call_command('backpopulate_user_tours')
assert UserTour.objects.count() == 2
new_tour = UserTour.objects.get(user=new_user)
assert new_tour.course_home_tour_status == UserTour.CourseHomeChoices.NEW_USER_TOUR
assert new_tour.show_courseware_tour
# But the old tour is left alone
tour = UserTour.objects.get(user=user)
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.EXISTING_USER_TOUR
assert not tour.show_courseware_tour

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.2.8 on 2021-11-19 15:58
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserTour',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('course_home_tour_status', models.CharField(choices=[('show-existing-user-tour', 'Show existing user tour'), ('show-new-user-tour', 'Show new user tour'), ('no-tour', 'Do not show user tour')], default='show-new-user-tour', max_length=50)),
('show_courseware_tour', models.BooleanField(default=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tour', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,27 @@
""" Models for the User Tour Experience. """
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
User = get_user_model()
class UserTour(models.Model):
"""
Model to track which tours a user needs to be shown.
Note: This does not track which tours a user has seen, only the ones they should.
.. no_pii:
"""
class CourseHomeChoices(models.TextChoices):
EXISTING_USER_TOUR = 'show-existing-user-tour', _('Show existing user tour')
NEW_USER_TOUR = 'show-new-user-tour', _('Show new user tour')
NO_TOUR = 'no-tour', _('Do not show user tour')
course_home_tour_status = models.CharField(
max_length=50, choices=CourseHomeChoices.choices, default=CourseHomeChoices.NEW_USER_TOUR
)
show_courseware_tour = models.BooleanField(default=True)
user = models.OneToOneField(User, related_name='tour', on_delete=models.CASCADE)

View File

@@ -0,0 +1,23 @@
""" Tests for UserTour Signal Handlers. """
from django.test import TestCase
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.user_tours.models import UserTour
class TestUserTourHandlers(TestCase):
""" Tests for UserTour Signal Handlers. """
def test_successful_handle(self):
"""
Tests a UserTour is created when a new User is created.
Then ensures a new UserTour is not created when the user is updated.
"""
assert UserTour.objects.count() == 0
user = UserFactory()
tour = UserTour.objects.get(user=user)
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.NEW_USER_TOUR
assert tour.show_courseware_tour is True
user.username = 'new-username'
user.save()
assert UserTour.objects.count() == 1

View File

@@ -0,0 +1,16 @@
"""
Toggles for the User Tours Experience.
"""
from edx_toggles.toggles import WaffleFlag
# .. toggle_name: user_tours.tours_enabled
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: This flag enables the use of user tours in the LMS.
# .. toggle_warnings: None
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-12-01
# .. toggle_target_removal_date: 2022-02-14
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-1026
USER_TOURS_ENABLED = WaffleFlag('user_tours.tours_enabled', module_name=__name__, log_prefix='user_tours')

View File

@@ -0,0 +1,11 @@
""" URLs for User Tours. """
from django.conf import settings
from django.urls import re_path
from lms.djangoapps.user_tours.v1.views import UserTourView
urlpatterns = [
re_path(fr'^v1/{settings.USERNAME_PATTERN}$', UserTourView.as_view(), name='user-tours'),
]

View File

View File

@@ -0,0 +1,11 @@
""" Serializer for UserTourView. """
from rest_framework import serializers
from lms.djangoapps.user_tours.models import UserTour
class UserTourSerializer(serializers.ModelSerializer):
class Meta:
model = UserTour
fields = ['course_home_tour_status', 'show_courseware_tour']

View File

@@ -0,0 +1,158 @@
""" Tests for v1 User Tour views. """
import ddt
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.test import TestCase
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from rest_framework import status
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.user_tours.handlers import init_user_tour
from lms.djangoapps.user_tours.models import UserTour
from lms.djangoapps.user_tours.toggles import USER_TOURS_ENABLED
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
User = get_user_model()
@ddt.ddt
@override_waffle_flag(USER_TOURS_ENABLED, active=True)
class TestUserTourView(TestCase):
""" Tests for the v1 User Tour views. """
def setUp(self):
""" Test set up. """
super().setUp()
self.user = UserFactory()
self.existing_user_tour = self.user.tour
self.existing_user_tour.course_home_tour_status = UserTour.CourseHomeChoices.EXISTING_USER_TOUR
self.existing_user_tour.show_courseware_tour = False
self.existing_user_tour.save()
self.staff_user = UserFactory(is_staff=True)
self.new_user_tour = self.staff_user.tour
def build_jwt_headers(self, user):
""" Helper function for creating headers for the JWT authentication. """
token = create_jwt_for_user(user)
headers = {'HTTP_AUTHORIZATION': f'JWT {token}'}
return headers
def send_request(self, jwt_user, request_user, method, data=None):
""" Helper function to call API. """
headers = self.build_jwt_headers(jwt_user)
url = reverse('user-tours', args=[request_user.username])
if method == 'GET':
return self.client.get(url, **headers)
elif method == 'PATCH':
return self.client.patch(url, data, content_type='application/json', **headers)
@ddt.data('GET', 'PATCH')
@override_waffle_flag(USER_TOURS_ENABLED, active=False)
def test_waffle_flag_off(self, method):
""" Test all endpoints if the waffle flag is turned off. """
response = self.send_request(self.staff_user, self.user, method)
assert response.status_code == status.HTTP_403_FORBIDDEN
@ddt.data('GET', 'PATCH')
def test_unauthorized_user(self, method):
""" Test all endpoints if request does not have jwt auth. """
url = reverse('user-tours', args=[self.user.username])
if method == 'GET':
response = self.client.get(url)
elif method == 'PATCH':
response = self.client.patch(url, data={})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_get_success(self):
""" Test GET request for a user. """
response = self.send_request(self.staff_user, self.staff_user, 'GET')
assert response.status_code == status.HTTP_200_OK
assert response.data == {
'course_home_tour_status': self.new_user_tour.course_home_tour_status,
'show_courseware_tour': self.new_user_tour.show_courseware_tour
}
def test_get_staff_user_for_other_user(self):
""" Test GET request for a staff user requesting info for another user. """
response = self.send_request(self.staff_user, self.user, 'GET')
assert response.status_code == status.HTTP_200_OK
assert response.data == {
'course_home_tour_status': self.existing_user_tour.course_home_tour_status,
'show_courseware_tour': self.existing_user_tour.show_courseware_tour
}
def test_get_user_for_other_user(self):
""" Test GET request for a regular user requesting info for another user. """
response = self.send_request(self.user, self.staff_user, 'GET')
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_get_nonexistent_user_tour(self):
"""
Test GET request for a non-existing user tour.
Note: In reality, this should never happen, but better safe than sorry
"""
# We need to disable the UserTour handler for this test since it will be automatically
# created otherwise.
post_save.disconnect(init_user_tour, sender=User)
response = self.send_request(self.staff_user, UserFactory(), 'GET')
assert response.status_code == status.HTTP_404_NOT_FOUND
post_save.connect(init_user_tour, sender=User)
def test_patch_success(self):
""" Test PATCH request for a user. """
tour = UserTour.objects.get(user=self.user)
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.EXISTING_USER_TOUR
data = {'course_home_tour_status': UserTour.CourseHomeChoices.NO_TOUR}
response = self.send_request(self.user, self.user, 'PATCH', data=data)
assert response.status_code == status.HTTP_200_OK
tour.refresh_from_db()
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.NO_TOUR
def test_patch_update_multiple_fields(self):
""" Test PATCH request for a user changing multiple fields. """
tour = UserTour.objects.get(user=self.staff_user)
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.NEW_USER_TOUR
assert tour.show_courseware_tour is True
data = {
'course_home_tour_status': UserTour.CourseHomeChoices.NO_TOUR,
'show_courseware_tour': False
}
response = self.send_request(self.staff_user, self.staff_user, 'PATCH', data=data)
assert response.status_code == status.HTTP_200_OK
tour.refresh_from_db()
assert tour.course_home_tour_status == UserTour.CourseHomeChoices.NO_TOUR
assert tour.show_courseware_tour is False
def test_patch_user_for_other_user(self):
""" Test PATCH request for a user trying to change UserTour status for another user. """
data = {'course_home_tour_status': UserTour.CourseHomeChoices.NO_TOUR}
response = self.send_request(self.staff_user, self.user, 'PATCH', data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_patch_bad_data(self):
""" Test PATCH request for a request with bad data. """
# Invalid value
data = {'course_home_tour_status': 'blah'}
response = self.send_request(self.user, self.user, 'PATCH', data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data['course_home_tour_status'][0] == '"blah" is not a valid choice.'
# Invalid param, dropped from validated data so no update happens
data = {'user': 7}
response = self.send_request(self.user, self.user, 'PATCH', data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Param that doesn't even exist on model, dropped from validated data so no update happens
data = {'foo': 'bar'}
response = self.send_request(self.user, self.user, 'PATCH', data=data)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_put_not_supported(self):
""" Test PUT request returns method not supported. """
headers = self.build_jwt_headers(self.staff_user)
url = reverse('user-tours', args=[self.staff_user.username])
response = self.client.put(url, data={}, content_type='application/json', **headers)
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

View File

@@ -0,0 +1,86 @@
""" API for User Tours. """
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from lms.djangoapps.user_tours.models import UserTour
from lms.djangoapps.user_tours.toggles import USER_TOURS_ENABLED
from lms.djangoapps.user_tours.v1.serializers import UserTourSerializer
class UserTourView(RetrieveUpdateAPIView):
"""
Supports retrieving and patching the UserTour model
**Example Requests**
GET /api/user_tours/v1/{username}
PATCH /api/user_tours/v1/{username}
"""
authentication_classes = (JwtAuthentication,)
permission_classes = (IsAuthenticated,)
serializer_class = UserTourSerializer
def get(self, request, username): # pylint: disable=arguments-differ
"""
Retrieve the User Tour for the given username.
Allows staff users to retrieve any user's User Tour.
Returns
200 with the following fields:
course_home_tour_status (str): one of UserTour.CourseHomeChoices
show_courseware_tour (bool): indicates if courseware tour should be shown.
400 if there is a not allowed request (requesting a user you don't have access to)
401 if unauthorized request
403 if waffle flag is not enabled
404 if the UserTour does not exist (shouldn't happen, but safety first)
"""
if not USER_TOURS_ENABLED.is_enabled():
return Response(status=status.HTTP_403_FORBIDDEN)
if request.user.username != username and not request.user.is_staff:
return Response(status=status.HTTP_400_BAD_REQUEST)
try:
user_tour = UserTour.objects.get(user__username=username)
# Should never really happen, but better safe than sorry.
except UserTour.DoesNotExist as e:
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(self.get_serializer_class()(user_tour).data, status=status.HTTP_200_OK)
def patch(self, request, username): # pylint: disable=arguments-differ
"""
Patch the User Tour for the request.user.
Supports updating the `course_home_tour_status` and `show_courseware_tour` fields.
Returns:
200 response if update was successful
400 if update was unsuccessful or there was nothing to update
401 if unauthorized request
403 if waffle flag is not enabled
"""
if not USER_TOURS_ENABLED.is_enabled():
return Response(status=status.HTTP_403_FORBIDDEN)
if request.user.username != username:
return Response(status=status.HTTP_400_BAD_REQUEST)
serializer = self.get_serializer_class()(data=request.data)
serializer.is_valid(raise_exception=True)
updated = UserTour.objects.filter(user__username=username).update(**serializer.validated_data)
if updated:
return Response(status=status.HTTP_200_OK)
else:
return Response(status=status.HTTP_400_BAD_REQUEST)
def put(self, *_args, **_kwargs):
""" Unsupported method. """
return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)

View File

@@ -2950,6 +2950,9 @@ INSTALLED_APPS = [
# Course home api
'lms.djangoapps.course_home_api',
# User tours
'lms.djangoapps.user_tours',
# New (Blockstore-based) XBlock runtime
'openedx.core.djangoapps.xblock.apps.LmsXBlockAppConfig',

View File

@@ -994,6 +994,11 @@ urlpatterns += [
path('api/course_home/v1/', include(('lms.djangoapps.course_home_api.urls', 'course-home-v1'))),
]
# User Tour API urls
urlpatterns += [
path('api/user_tours/', include('lms.djangoapps.user_tours.urls')),
]
# Course Experience API urls
urlpatterns += [
path('api/course_experience/', include('openedx.features.course_experience.api.v1.urls')),