From 2f2a6317a5f44e342aa7a8c6639b17f8c335beda Mon Sep 17 00:00:00 2001 From: Dillon Dumesnil Date: Wed, 10 Nov 2021 12:19:45 -0500 Subject: [PATCH] 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. --- .github/workflows/pylint-checks.yml | 2 +- .github/workflows/unit-tests.yml | 2 +- lms/djangoapps/user_tours/__init__.py | 0 lms/djangoapps/user_tours/admin.py | 13 ++ lms/djangoapps/user_tours/apps.py | 13 ++ lms/djangoapps/user_tours/handlers.py | 29 ++++ .../user_tours/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/backpopulate_user_tours.py | 40 +++++ .../management/commands/tests/__init__.py | 0 .../tests/test_backpopulate_user_tours.py | 89 ++++++++++ .../user_tours/migrations/0001_initial.py | 26 +++ .../user_tours/migrations/__init__.py | 0 lms/djangoapps/user_tours/models.py | 27 +++ lms/djangoapps/user_tours/tests/__init__.py | 0 .../user_tours/tests/test_handlers.py | 23 +++ lms/djangoapps/user_tours/toggles.py | 16 ++ lms/djangoapps/user_tours/urls.py | 11 ++ lms/djangoapps/user_tours/v1/__init__.py | 0 lms/djangoapps/user_tours/v1/serializers.py | 11 ++ .../user_tours/v1/tests/__init__.py | 0 .../user_tours/v1/tests/test_views.py | 158 ++++++++++++++++++ lms/djangoapps/user_tours/v1/views.py | 86 ++++++++++ lms/envs/common.py | 3 + lms/urls.py | 5 + 25 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/user_tours/__init__.py create mode 100644 lms/djangoapps/user_tours/admin.py create mode 100644 lms/djangoapps/user_tours/apps.py create mode 100644 lms/djangoapps/user_tours/handlers.py create mode 100644 lms/djangoapps/user_tours/management/__init__.py create mode 100644 lms/djangoapps/user_tours/management/commands/__init__.py create mode 100644 lms/djangoapps/user_tours/management/commands/backpopulate_user_tours.py create mode 100644 lms/djangoapps/user_tours/management/commands/tests/__init__.py create mode 100644 lms/djangoapps/user_tours/management/commands/tests/test_backpopulate_user_tours.py create mode 100644 lms/djangoapps/user_tours/migrations/0001_initial.py create mode 100644 lms/djangoapps/user_tours/migrations/__init__.py create mode 100644 lms/djangoapps/user_tours/models.py create mode 100644 lms/djangoapps/user_tours/tests/__init__.py create mode 100644 lms/djangoapps/user_tours/tests/test_handlers.py create mode 100644 lms/djangoapps/user_tours/toggles.py create mode 100644 lms/djangoapps/user_tours/urls.py create mode 100644 lms/djangoapps/user_tours/v1/__init__.py create mode 100644 lms/djangoapps/user_tours/v1/serializers.py create mode 100644 lms/djangoapps/user_tours/v1/tests/__init__.py create mode 100644 lms/djangoapps/user_tours/v1/tests/test_views.py create mode 100644 lms/djangoapps/user_tours/v1/views.py diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index d6b466e6f6..dc94f9c442 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -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 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 72dcb1101c..cf1feeaba2 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -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/", diff --git a/lms/djangoapps/user_tours/__init__.py b/lms/djangoapps/user_tours/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/admin.py b/lms/djangoapps/user_tours/admin.py new file mode 100644 index 0000000000..2d59527a6b --- /dev/null +++ b/lms/djangoapps/user_tours/admin.py @@ -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',) diff --git a/lms/djangoapps/user_tours/apps.py b/lms/djangoapps/user_tours/apps.py new file mode 100644 index 0000000000..7ec2de59a9 --- /dev/null +++ b/lms/djangoapps/user_tours/apps.py @@ -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 diff --git a/lms/djangoapps/user_tours/handlers.py b/lms/djangoapps/user_tours/handlers.py new file mode 100644 index 0000000000..60d14827c5 --- /dev/null +++ b/lms/djangoapps/user_tours/handlers.py @@ -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 diff --git a/lms/djangoapps/user_tours/management/__init__.py b/lms/djangoapps/user_tours/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/management/commands/__init__.py b/lms/djangoapps/user_tours/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/management/commands/backpopulate_user_tours.py b/lms/djangoapps/user_tours/management/commands/backpopulate_user_tours.py new file mode 100644 index 0000000000..60ab896bf3 --- /dev/null +++ b/lms/djangoapps/user_tours/management/commands/backpopulate_user_tours.py @@ -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, + }, + ) diff --git a/lms/djangoapps/user_tours/management/commands/tests/__init__.py b/lms/djangoapps/user_tours/management/commands/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/management/commands/tests/test_backpopulate_user_tours.py b/lms/djangoapps/user_tours/management/commands/tests/test_backpopulate_user_tours.py new file mode 100644 index 0000000000..3812083999 --- /dev/null +++ b/lms/djangoapps/user_tours/management/commands/tests/test_backpopulate_user_tours.py @@ -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 diff --git a/lms/djangoapps/user_tours/migrations/0001_initial.py b/lms/djangoapps/user_tours/migrations/0001_initial.py new file mode 100644 index 0000000000..b539347388 --- /dev/null +++ b/lms/djangoapps/user_tours/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/lms/djangoapps/user_tours/migrations/__init__.py b/lms/djangoapps/user_tours/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/models.py b/lms/djangoapps/user_tours/models.py new file mode 100644 index 0000000000..407028a476 --- /dev/null +++ b/lms/djangoapps/user_tours/models.py @@ -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) diff --git a/lms/djangoapps/user_tours/tests/__init__.py b/lms/djangoapps/user_tours/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/tests/test_handlers.py b/lms/djangoapps/user_tours/tests/test_handlers.py new file mode 100644 index 0000000000..40b7f4820b --- /dev/null +++ b/lms/djangoapps/user_tours/tests/test_handlers.py @@ -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 diff --git a/lms/djangoapps/user_tours/toggles.py b/lms/djangoapps/user_tours/toggles.py new file mode 100644 index 0000000000..653d1cb12d --- /dev/null +++ b/lms/djangoapps/user_tours/toggles.py @@ -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') diff --git a/lms/djangoapps/user_tours/urls.py b/lms/djangoapps/user_tours/urls.py new file mode 100644 index 0000000000..f7870d54cc --- /dev/null +++ b/lms/djangoapps/user_tours/urls.py @@ -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'), +] diff --git a/lms/djangoapps/user_tours/v1/__init__.py b/lms/djangoapps/user_tours/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/v1/serializers.py b/lms/djangoapps/user_tours/v1/serializers.py new file mode 100644 index 0000000000..442a55e7f1 --- /dev/null +++ b/lms/djangoapps/user_tours/v1/serializers.py @@ -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'] diff --git a/lms/djangoapps/user_tours/v1/tests/__init__.py b/lms/djangoapps/user_tours/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/user_tours/v1/tests/test_views.py b/lms/djangoapps/user_tours/v1/tests/test_views.py new file mode 100644 index 0000000000..ccdba9b9f6 --- /dev/null +++ b/lms/djangoapps/user_tours/v1/tests/test_views.py @@ -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 diff --git a/lms/djangoapps/user_tours/v1/views.py b/lms/djangoapps/user_tours/v1/views.py new file mode 100644 index 0000000000..616752c1ba --- /dev/null +++ b/lms/djangoapps/user_tours/v1/views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index fb230fd694..fd7a56ce36 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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', diff --git a/lms/urls.py b/lms/urls.py index 62e5924be1..9424d59847 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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')),