From 0870e1a7586b2c5b1e126508b7c3113010a17bba Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Tue, 17 Jan 2023 15:59:26 +0500 Subject: [PATCH] feat: added apis for discussions tours (#31495) * feat: added apis for discussions tours * fix: resolved linter issues * feat: moved config list to settings and fixed unit tests * refactor: merged migrations in 1 file --- .../migrations/0002_auto_20230110_0905.py | 29 ++++++ lms/djangoapps/user_tours/models.py | 14 +++ lms/djangoapps/user_tours/urls.py | 3 +- lms/djangoapps/user_tours/v1/serializers.py | 21 +++- .../user_tours/v1/tests/test_views.py | 89 +++++++++++++++-- lms/djangoapps/user_tours/v1/views.py | 99 ++++++++++++++++++- lms/envs/common.py | 2 + lms/envs/production.py | 3 + 8 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 lms/djangoapps/user_tours/migrations/0002_auto_20230110_0905.py diff --git a/lms/djangoapps/user_tours/migrations/0002_auto_20230110_0905.py b/lms/djangoapps/user_tours/migrations/0002_auto_20230110_0905.py new file mode 100644 index 0000000000..27db0c0add --- /dev/null +++ b/lms/djangoapps/user_tours/migrations/0002_auto_20230110_0905.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.16 on 2023-01-10 09:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('user_tours', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserDiscussionsTours', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tour_name', models.CharField(max_length=255)), + ('show_tour', models.BooleanField(default=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddIndex( + model_name='userdiscussionstours', + index=models.Index(fields=['user', 'tour_name'], name='user_tours__user_id_0397d5_idx'), + ), + ] diff --git a/lms/djangoapps/user_tours/models.py b/lms/djangoapps/user_tours/models.py index 407028a476..34bd7b28de 100644 --- a/lms/djangoapps/user_tours/models.py +++ b/lms/djangoapps/user_tours/models.py @@ -25,3 +25,17 @@ class UserTour(models.Model): ) show_courseware_tour = models.BooleanField(default=True) user = models.OneToOneField(User, related_name='tour', on_delete=models.CASCADE) + + +class UserDiscussionsTours(models.Model): + """ + Model to track which discussions tours a user has seen. + """ + tour_name = models.CharField(max_length=255) + show_tour = models.BooleanField(default=True) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + indexes = [ + models.Index(fields=['user', 'tour_name']), + ] diff --git a/lms/djangoapps/user_tours/urls.py b/lms/djangoapps/user_tours/urls.py index f7870d54cc..63e2e2e06f 100644 --- a/lms/djangoapps/user_tours/urls.py +++ b/lms/djangoapps/user_tours/urls.py @@ -3,9 +3,10 @@ from django.conf import settings from django.urls import re_path -from lms.djangoapps.user_tours.v1.views import UserTourView +from lms.djangoapps.user_tours.v1.views import UserTourView, UserDiscussionsToursView urlpatterns = [ re_path(fr'^v1/{settings.USERNAME_PATTERN}$', UserTourView.as_view(), name='user-tours'), + re_path(r'^discussion_tours/(?P\d+)?/?$', UserDiscussionsToursView.as_view(), name='discussion-tours'), ] diff --git a/lms/djangoapps/user_tours/v1/serializers.py b/lms/djangoapps/user_tours/v1/serializers.py index 442a55e7f1..32bb636571 100644 --- a/lms/djangoapps/user_tours/v1/serializers.py +++ b/lms/djangoapps/user_tours/v1/serializers.py @@ -2,10 +2,29 @@ from rest_framework import serializers -from lms.djangoapps.user_tours.models import UserTour +from lms.djangoapps.user_tours.models import UserTour, UserDiscussionsTours class UserTourSerializer(serializers.ModelSerializer): class Meta: model = UserTour fields = ['course_home_tour_status', 'show_courseware_tour'] + + +class UserDiscussionsToursSerializer(serializers.ModelSerializer): + """ + Serializer for UserDiscussionsTours model. + """ + + id = serializers.IntegerField(read_only=True) + tour_name = serializers.CharField(read_only=True) + user = serializers.PrimaryKeyRelatedField(read_only=True) + + class Meta: + model = UserDiscussionsTours + fields = ['id', 'tour_name', 'show_tour', 'user'] + + def to_representation(self, instance): + # Convert the status field to a boolean value + instance.show_tour = bool(instance.show_tour) + return super().to_representation(instance) diff --git a/lms/djangoapps/user_tours/v1/tests/test_views.py b/lms/djangoapps/user_tours/v1/tests/test_views.py index b07cf5a807..57aa7ca5dc 100644 --- a/lms/djangoapps/user_tours/v1/tests/test_views.py +++ b/lms/djangoapps/user_tours/v1/tests/test_views.py @@ -3,21 +3,29 @@ 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.test import TestCase, override_settings from django.urls import reverse 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.models import UserTour, UserDiscussionsTours from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user User = get_user_model() +def build_jwt_headers(user): + """ Helper function for creating headers for the JWT authentication. """ + token = create_jwt_for_user(user) + headers = {'HTTP_AUTHORIZATION': f'JWT {token}'} + return headers + + @ddt.ddt class TestUserTourView(TestCase): """ Tests for the v1 User Tour views. """ + def setUp(self): """ Test set up. """ super().setUp() @@ -30,15 +38,9 @@ class TestUserTourView(TestCase): 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) + headers = build_jwt_headers(jwt_user) url = reverse('user-tours', args=[request_user.username]) if method == 'GET': return self.client.get(url, **headers) @@ -142,7 +144,74 @@ class TestUserTourView(TestCase): def test_put_not_supported(self): """ Test PUT request returns method not supported. """ - headers = self.build_jwt_headers(self.staff_user) + headers = 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 + + +@override_settings(AVAILABLE_DISCUSSION_TOURS=['not_responded_filter']) +class UserDiscussionsToursViewTestCase(TestCase): + """ + Tests for the UserDiscussionsToursView view. + """ + def setUp(self): + # Create a user and a tour for testing + self.user = User.objects.create_user( + username='testuser', + password='testpass' + ) + # create a UserDiscussionsTour for the user + self.tour = UserDiscussionsTours.objects.create( + tour_name='Test Tour', + show_tour=False, + user=self.user + ) + self.url = reverse('discussion-tours') + + def test_get_tours(self): + """ + Test GET request for a user's discussion tours. + """ + # create a header with our user's credentials + headers = build_jwt_headers(self.user) + # Send a GET request to the view + response = self.client.get(self.url, **headers) + # Check that the response status code is correct + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Check that the returned data is correct + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['tour_name'], 'Test Tour') + self.assertFalse(response.data[0]['show_tour']) + + self.assertEqual(response.data[1]['tour_name'], 'not_responded_filter') + self.assertTrue(response.data[1]['show_tour']) + + def test_get_tours_unauthenticated(self): + """ + Test that an unauthenticated user cannot access the discussion tours endpoint. + """ + # Send a GET request to the view without logging in + response = self.client.get(self.url) + + # Check that the response status code is correct + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_tour(self): + """ + Test that a user can update their discussion tour status. + """ + headers = build_jwt_headers(self.user) + + # Send a PUT request to the view with the updated tour data + updated_data = {'show_tour': False} + url = reverse('discussion-tours', args=[self.tour.id]) + response = self.client.put(url, updated_data, content_type='application/json', **headers) + + # Check that the response status code is correct + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Check that the tour was updated in the database + updated_tour = UserDiscussionsTours.objects.get(id=self.tour.id) + self.assertEqual(updated_tour.show_tour, False) diff --git a/lms/djangoapps/user_tours/v1/views.py b/lms/djangoapps/user_tours/v1/views.py index f071f22540..dca1964b64 100644 --- a/lms/djangoapps/user_tours/v1/views.py +++ b/lms/djangoapps/user_tours/v1/views.py @@ -1,13 +1,18 @@ """ API for User Tours. """ - +from django.conf import settings +from django.db import transaction, IntegrityError +from django.shortcuts import get_object_or_404 from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework.authentication import SessionAuthentication 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.v1.serializers import UserTourSerializer +from lms.djangoapps.user_tours.models import UserTour, UserDiscussionsTours +from lms.djangoapps.user_tours.v1.serializers import UserTourSerializer, UserDiscussionsToursSerializer + +from rest_framework.views import APIView class UserTourView(RetrieveUpdateAPIView): @@ -77,3 +82,91 @@ class UserTourView(RetrieveUpdateAPIView): def put(self, *_args, **_kwargs): """ Unsupported method. """ return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + +class UserDiscussionsToursView(APIView): + """ + Supports retrieving and patching the UserDiscussionsTours model + returns a list of available tours and their status + + **Example Requests** + GET /api/user_tours/v1/discussions/ + PUT /api/user_tours/v1/discussions/{tour_id} + + **Example Response**: + [ + { + "id": 1, + "tour_name": "discussions", + "show_tour": true, + "user": 1 + } + ] + """ + + authentication_classes = (JwtAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + + def get(self, request, tour_id=None): + """ + Return a list of all tours in the database. + + Parameters: + request (Request): The request object + tour_id (int): The ID of the tour to be retrieved. + + Returns: + 200: A list of tours, serialized using the UserDiscussionsToursSerializer + [ + { + "id": 1, + "tour_name": "discussions", + "show_tour": true, + "user": 1 + } + ] + + """ + try: + with transaction.atomic(): + tours = UserDiscussionsTours.objects.filter(user=request.user) + + tours_to_create = [] + for tour_name in settings.AVAILABLE_DISCUSSION_TOURS: + if tour_name not in [tour.tour_name for tour in tours]: + tours_to_create.append(UserDiscussionsTours( + tour_name=tour_name, + user=request.user, + show_tour=True + )) + + UserDiscussionsTours.objects.bulk_create(tours_to_create) + tours = UserDiscussionsTours.objects.filter(user=request.user) + serializer = UserDiscussionsToursSerializer(tours, many=True) + return Response(serializer.data) + except IntegrityError: + return Response(status=status.HTTP_409_CONFLICT) + + def put(self, request, tour_id): + """ + Update an existing tour with the data in the request body. + + Parameters: + request (Request): The request object + tour_id (int): The ID of the tour to be updated. + + Returns: + 200: The updated tour, serialized using the UserDiscussionsToursSerializer + 404: If the tour does not exist + 403: If the user does not have permission to update the tour + 400: Validation error + """ + tour = get_object_or_404(UserDiscussionsTours, pk=tour_id) + if tour.user != request.user: + return Response(status=status.HTTP_403_FORBIDDEN) + + serializer = UserDiscussionsToursSerializer(tour, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/lms/envs/common.py b/lms/envs/common.py index 94ca721bd6..e62bbbfe65 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5283,3 +5283,5 @@ ENTERPRISE_PLOTLY_SECRET = "I am a secret" ############## PLOTLY ############## ENTERPRISE_MANUAL_REPORTING_CUSTOMER_UUIDS = [] + +AVAILABLE_DISCUSSION_TOURS = [] diff --git a/lms/envs/production.py b/lms/envs/production.py index 8455f76948..9352be3bb0 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -1083,3 +1083,6 @@ COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = { ############## Settings for survey report ############## SURVEY_REPORT_EXTRA_DATA = ENV_TOKENS.get('SURVEY_REPORT_EXTRA_DATA', {}) + + +AVAILABLE_DISCUSSION_TOURS = ENV_TOKENS.get('AVAILABLE_DISCUSSION_TOURS', [])