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')),