Merge pull request #29301 from edx/ddumesnil/user-tours-aa-1055
feat: AA-1055: Add in User Tours to the platform
This commit is contained in:
2
.github/workflows/pylint-checks.yml
vendored
2
.github/workflows/pylint-checks.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/unit-tests.yml
vendored
2
.github/workflows/unit-tests.yml
vendored
@@ -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/",
|
||||
|
||||
0
lms/djangoapps/user_tours/__init__.py
Normal file
0
lms/djangoapps/user_tours/__init__.py
Normal file
13
lms/djangoapps/user_tours/admin.py
Normal file
13
lms/djangoapps/user_tours/admin.py
Normal 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',)
|
||||
13
lms/djangoapps/user_tours/apps.py
Normal file
13
lms/djangoapps/user_tours/apps.py
Normal 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
|
||||
29
lms/djangoapps/user_tours/handlers.py
Normal file
29
lms/djangoapps/user_tours/handlers.py
Normal 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
|
||||
0
lms/djangoapps/user_tours/management/__init__.py
Normal file
0
lms/djangoapps/user_tours/management/__init__.py
Normal 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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
26
lms/djangoapps/user_tours/migrations/0001_initial.py
Normal file
26
lms/djangoapps/user_tours/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
lms/djangoapps/user_tours/migrations/__init__.py
Normal file
0
lms/djangoapps/user_tours/migrations/__init__.py
Normal file
27
lms/djangoapps/user_tours/models.py
Normal file
27
lms/djangoapps/user_tours/models.py
Normal 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)
|
||||
0
lms/djangoapps/user_tours/tests/__init__.py
Normal file
0
lms/djangoapps/user_tours/tests/__init__.py
Normal file
23
lms/djangoapps/user_tours/tests/test_handlers.py
Normal file
23
lms/djangoapps/user_tours/tests/test_handlers.py
Normal 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
|
||||
16
lms/djangoapps/user_tours/toggles.py
Normal file
16
lms/djangoapps/user_tours/toggles.py
Normal 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')
|
||||
11
lms/djangoapps/user_tours/urls.py
Normal file
11
lms/djangoapps/user_tours/urls.py
Normal 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'),
|
||||
]
|
||||
0
lms/djangoapps/user_tours/v1/__init__.py
Normal file
0
lms/djangoapps/user_tours/v1/__init__.py
Normal file
11
lms/djangoapps/user_tours/v1/serializers.py
Normal file
11
lms/djangoapps/user_tours/v1/serializers.py
Normal 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']
|
||||
0
lms/djangoapps/user_tours/v1/tests/__init__.py
Normal file
0
lms/djangoapps/user_tours/v1/tests/__init__.py
Normal file
158
lms/djangoapps/user_tours/v1/tests/test_views.py
Normal file
158
lms/djangoapps/user_tours/v1/tests/test_views.py
Normal 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
|
||||
86
lms/djangoapps/user_tours/v1/views.py
Normal file
86
lms/djangoapps/user_tours/v1/views.py
Normal 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)
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
Reference in New Issue
Block a user