diff --git a/lms/djangoapps/course_home_api/__init__.py b/lms/djangoapps/course_home_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_home_api/dates/v1/__init__.py b/lms/djangoapps/course_home_api/dates/v1/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_home_api/dates/v1/serializers.py b/lms/djangoapps/course_home_api/dates/v1/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..d142ca0982de5c583cd438ade7ef9f0c48829939 --- /dev/null +++ b/lms/djangoapps/course_home_api/dates/v1/serializers.py @@ -0,0 +1,43 @@ +""" +Dates Tab Serializers. Represents the relevant dates for a Course. +""" + + +from rest_framework import serializers + +from lms.djangoapps.courseware.date_summary import VerificationDeadlineDate + + +class DateSummarySerializer(serializers.Serializer): + """ + Serializer for Date Summary Objects. + """ + date = serializers.DateTimeField() + date_type = serializers.CharField() + description = serializers.CharField() + learner_has_access = serializers.SerializerMethodField() + link = serializers.SerializerMethodField() + title = serializers.CharField() + + def get_learner_has_access(self, block): + learner_is_verified = self.context.get('learner_is_verified', False) + block_is_verified = (getattr(block, 'contains_gated_content', False) or + isinstance(block, VerificationDeadlineDate)) + return (not block_is_verified) or learner_is_verified + + def get_link(self, block): + if block.link: + request = self.context.get('request') + return request.build_absolute_uri(block.link) + return '' + + +class DatesTabSerializer(serializers.Serializer): + """ + Serializer for the Dates Tab + """ + course_date_blocks = DateSummarySerializer(many=True) + display_reset_dates_text = serializers.BooleanField() + learner_is_verified = serializers.BooleanField() + user_timezone = serializers.CharField() + verified_upgrade_link = serializers.URLField() diff --git a/lms/djangoapps/course_home_api/dates/v1/tests/__init__.py b/lms/djangoapps/course_home_api/dates/v1/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_home_api/dates/v1/tests/test_views.py b/lms/djangoapps/course_home_api/dates/v1/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..604765fe1520e69fc6755feb2824daf5e642055c --- /dev/null +++ b/lms/djangoapps/course_home_api/dates/v1/tests/test_views.py @@ -0,0 +1,54 @@ +""" +Tests for Dates Tab API in the Course Home API +""" + + +import ddt + +from django.urls import reverse + +from course_modes.models import CourseMode +from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests +from student.models import CourseEnrollment + + +@ddt.ddt +class DatesTabTestViews(BaseCourseHomeTests): + """ + Tests for the Dates Tab API + """ + @classmethod + def setUpClass(cls): + BaseCourseHomeTests.setUpClass() + cls.url = reverse('course-home-dates-tab', args=[cls.course.id]) + + @ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED) + def test_get_authenticated_enrolled_user(self, enrollment_mode): + CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + # Pulling out the date blocks to check learner has access. The Verification Deadline Date + # should not be accessible to the audit learner, but accessible to the verified learner. + date_blocks = response.data.get('course_date_blocks') + if enrollment_mode == CourseMode.AUDIT: + self.assertFalse(response.data.get('learner_is_verified')) + self.assertTrue(any(block.get('learner_has_access') is False for block in date_blocks)) + else: + self.assertTrue(response.data.get('learner_is_verified')) + self.assertTrue(all(block.get('learner_has_access') for block in date_blocks)) + + def test_get_authenticated_user_not_enrolled(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data.get('learner_is_verified')) + + def test_get_unauthenticated_user(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_get_unknown_course(self): + url = reverse('course-home-dates-tab', args=['course-v1:unknown+course+2T2020']) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) diff --git a/lms/djangoapps/course_home_api/dates/v1/views.py b/lms/djangoapps/course_home_api/dates/v1/views.py new file mode 100644 index 0000000000000000000000000000000000000000..7a55fdd923fc8a6866085d9bff9d5ed3c6c4c1c7 --- /dev/null +++ b/lms/djangoapps/course_home_api/dates/v1/views.py @@ -0,0 +1,90 @@ +""" +Dates Tab Views +""" + + +from rest_framework.generics import RetrieveAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from edx_django_utils import monitoring as monitoring_utils +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs +from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_with_access +from lms.djangoapps.courseware.date_summary import TodaysDate, verified_upgrade_deadline_link +from lms.djangoapps.course_home_api.dates.v1.serializers import DatesTabSerializer +from openedx.core.djangoapps.enrollments.api import get_enrollment +from openedx.features.course_experience.utils import reset_deadlines_banner_should_display + + +class DatesTabView(RetrieveAPIView): + """ + **Use Cases** + + Request details for the Dates Tab + + **Example Requests** + + GET api/course_home/v1/dates/{course_key} + + **Response Values** + + Body consists of the following fields: + + course_date_blocks: List of serialized DateSummary objects. Each serialization has the following fields: + date: (datetime) The date time corresponding for the event + date_type: (str) The type of date (ex. course-start-date, assignment-due-date, etc.) + description: (str) The description for the date event + learner_has_access: (bool) Indicates if the learner has access to the date event + link: (str) An absolute link to content related to the date event + (ex. verified link or link to assignment) + title: (str) The title of the date event + display_reset_dates_text: (bool) Indicates whether the reset dates banner should be shown + for the given user + learner_is_verified: (bool) Indicates if the user is verified in the course + user_timezone: (str) The user's preferred timezone + verified_upgrade_link: (str) The link for upgrading to the Verified track in a course + + **Returns** + + * 200 on success with above fields. + * 403 if the user is not authenticated. + * 404 if the course is not available or cannot be seen. + """ + + permission_classes = (IsAuthenticated,) + serializer_class = DatesTabSerializer + + def get(self, request, course_key_string): + # Enable NR tracing for this view based on course + monitoring_utils.set_custom_metric('course_id', course_key_string) + monitoring_utils.set_custom_metric('user_id', request.user.id) + monitoring_utils.set_custom_metric('is_staff', request.user.is_staff) + + course_key = CourseKey.from_string(course_key_string) + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=False) + blocks = get_course_date_blocks(course, request.user, request, include_access=True, include_past_dates=True) + display_reset_dates_text = reset_deadlines_banner_should_display(course_key, request) + + learner_is_verified = False + enrollment = get_enrollment(request.user.username, course_key_string) + if enrollment: + learner_is_verified = enrollment.get('mode') == 'verified' + + # User locale settings + user_timezone_locale = user_timezone_locale_prefs(request) + user_timezone = user_timezone_locale['user_timezone'] + + data = { + 'course_date_blocks': [block for block in blocks if not isinstance(block, TodaysDate)], + 'display_reset_dates_text': display_reset_dates_text, + 'learner_is_verified': learner_is_verified, + 'user_timezone': user_timezone, + 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), + } + context = self.get_serializer_context() + context['learner_is_verified'] = learner_is_verified + serializer = self.get_serializer_class()(data, context=context) + + return Response(serializer.data) diff --git a/lms/djangoapps/course_home_api/tests/__init__.py b/lms/djangoapps/course_home_api/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_home_api/tests/utils.py b/lms/djangoapps/course_home_api/tests/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1fd56f4e496eab5f2a5faa56db10f351ea0eebda --- /dev/null +++ b/lms/djangoapps/course_home_api/tests/utils.py @@ -0,0 +1,68 @@ +""" +Base classes or util functions for use in Course Home API tests +""" + + +import unittest + +from datetime import datetime +from django.conf import settings + +from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory +from lms.djangoapps.verify_student.models import VerificationDeadline +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from student.tests.factories import UserFactory +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class BaseCourseHomeTests(SharedModuleStoreTestCase): + """ + Base class for Course Home API tests. + + Creates a course to + """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.store = modulestore() + cls.course = CourseFactory.create( + start=datetime(2020, 1, 1), + end=datetime(2028, 1, 1), + enrollment_start=datetime(2020, 1, 1), + enrollment_end=datetime(2028, 1, 1), + emit_signals=True, + modulestore=cls.store, + ) + chapter = ItemFactory(parent=cls.course, category='chapter') + ItemFactory(parent=chapter, category='sequential', display_name='sequence') + + CourseModeFactory(course_id=cls.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory( + course_id=cls.course.id, + mode_slug=CourseMode.VERIFIED, + expiration_datetime=datetime(2028, 1, 1) + ) + VerificationDeadline.objects.create(course_key=cls.course.id, deadline=datetime(2028, 1, 1)) + + cls.user = UserFactory( + username='student', + email='user@example.com', + password='foo', + is_staff=False + ) + CourseOverviewFactory.create(run='1T2020') + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + cls.store.delete_course(cls.course.id, cls.user.id) + + def setUp(self): + super().setUp() + self.client.login(username=self.user.username, password='foo') diff --git a/lms/djangoapps/course_home_api/urls.py b/lms/djangoapps/course_home_api/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..07753b3330f0eaa9d12ce48b501099e792f68c5c --- /dev/null +++ b/lms/djangoapps/course_home_api/urls.py @@ -0,0 +1,20 @@ +""" +Contains all the URLs for the Course Home +""" + + +from django.conf import settings +from django.urls import re_path + +from lms.djangoapps.course_home_api.dates.v1 import views + +urlpatterns = [] + +# Dates Tab URLs +urlpatterns += [ + re_path( + r'v1/dates/{}'.format(settings.COURSE_KEY_PATTERN), + views.DatesTabView.as_view(), + name='course-home-dates-tab' + ), +] diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 40e8261c2df030ac9ad1b894a6e9619a5dd1f1fd..3836ed9fc105e1624ab690542b2d0748e3c10e21 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -6,7 +6,7 @@ courseware. import logging from collections import defaultdict, namedtuple -from datetime import datetime, timedelta +from datetime import datetime import pytz import six @@ -60,7 +60,6 @@ from openedx.core.lib.api.view_utils import LazySequence from openedx.features.course_duration_limits.access import AuditExpiredError from openedx.features.course_experience import RELATIVE_DATES_FLAG from static_replace import replace_static_urls -from student.models import CourseEnrollment from survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered from util.date_utils import strftime_localized from xmodule.modulestore.django import modulestore @@ -503,6 +502,7 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None, date_block.contains_gated_content = assignment.contains_gated_content date_block.complete = assignment.complete date_block.past_due = assignment.past_due + date_block.link = assignment.url date_block.set_title(assignment.title, link=assignment.url) date_blocks.append(date_block) date_blocks = sorted((b for b in date_blocks if b.is_enabled or include_past_dates), key=date_block_key_fn) @@ -534,7 +534,7 @@ def get_course_assignments(course_key, user, request, include_access=False): subsection_key, 'contains_gated_content', False) title = block_data.get_xblock_field(subsection_key, 'display_name', _('Assignment')) - url = None + url = '' start = block_data.get_xblock_field(subsection_key, 'start') assignment_released = not start or start < now if assignment_released: diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 822ea730af742037544dd22d2275ec00167d77bb..49a41c3d20ea1fbae0ae17f7e27380e4125ea089 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -57,6 +57,10 @@ class DateSummary(object): """ return '' + @property + def date_type(self): + return 'event' + @property def title(self): """The title of this summary.""" @@ -226,6 +230,10 @@ class TodaysDate(DateSummary): def date(self): return self.current_time + @property + def date_type(self): + return 'todays-date' + @property def title(self): return 'current_datetime' @@ -242,6 +250,10 @@ class CourseStartDate(DateSummary): def date(self): return self.course.start + @property + def date_type(self): + return 'course-start-date' + def register_alerts(self, request, course): """ Registers an alert if the course has not started yet. @@ -305,6 +317,10 @@ class CourseEndDate(DateSummary): return self.course.end + @property + def date_type(self): + return 'course-end-date' + def register_alerts(self, request, course): """ Registers an alert if the end date is approaching. @@ -344,6 +360,7 @@ class CourseAssignmentDate(DateSummary): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.assignment_date = None + self.assignment_link = '' self.assignment_title = None self.assignment_title_html = None self.contains_gated_content = False @@ -358,6 +375,18 @@ class CourseAssignmentDate(DateSummary): def date(self, date): self.assignment_date = date + @property + def date_type(self): + return 'assignment-due-date' + + @property + def link(self): + return self.assignment_link + + @link.setter + def link(self, link): + self.assignment_link = link + @property def title(self): return self.assignment_title @@ -387,6 +416,10 @@ class CourseExpiredDate(DateSummary): return return get_user_course_expiration_date(self.user, self.course) + @property + def date_type(self): + return 'course-expired-date' + @property def description(self): return _('You lose all access to this course, including your progress.') @@ -428,6 +461,10 @@ class CertificateAvailableDate(DateSummary): def date(self): return self.course.certificate_available_date + @property + def date_type(self): + return 'certificate-available-date' + @property def has_certificate_modes(self): return any([ @@ -499,6 +536,10 @@ class VerifiedUpgradeDeadlineDate(DateSummary): else: return None + @property + def date_type(self): + return 'verified-upgrade-deadline' + @property def title(self): dynamic_deadline = self._dynamic_deadline() @@ -635,6 +676,10 @@ class VerificationDeadlineDate(DateSummary): def date(self): return VerificationDeadline.deadline_for_course(self.course_id) + @property + def date_type(self): + return 'verification-deadline-date' + @lazy def is_enabled(self): if self.date is None: diff --git a/lms/templates/courseware/dates.html b/lms/templates/courseware/dates.html index 6b13518f958fa7a9a31bf014013daa3b6edc5e63..c4a4bdab83a92d011d185f977190727eff6e63ae 100644 --- a/lms/templates/courseware/dates.html +++ b/lms/templates/courseware/dates.html @@ -35,7 +35,7 @@ from openedx.core.djangolib.markup import HTML, Text </div> </div> % endif - <% has_locked_assignments = any(hasattr(block, 'contains_gated_content') and block.contains_gated_content for block in course_date_blocks if isinstance(block, CourseAssignmentDate)) %> + <% has_locked_assignments = any(hasattr(block, 'contains_gated_content') and block.contains_gated_content for block in course_date_blocks) %> % if has_locked_assignments and verified_upgrade_link: <div class="dates-banner"> <div class="dates-banner-text banner-has-button"> diff --git a/lms/urls.py b/lms/urls.py index 6e6c1943a70719d7935a31e451bccaf2d0c18955..0d4727be174cf36c96b9f87191f34fd1e0009187 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -999,3 +999,8 @@ if 'openedx.testing.coverage_context_listener' in settings.INSTALLED_APPS: ] urlpatterns.extend(plugin_urls.get_patterns(plugin_constants.ProjectType.LMS)) + +# Course Home API urls +urlpatterns += [ + url(r'^api/course_home/', include('lms.djangoapps.course_home_api.urls')), +]