feat: allow unsubcribing from a course goal with just a token
* Add unsubscribe_token uuid field to CourseGoal model * Add endpoint to unsubcribe from just a token (no login needed) * Add admin page for the course_goals djangoapp * Add get_course_overview_or_404 utility method * Clean up URL handling in course_home_api AA-907
This commit is contained in:
@@ -7,10 +7,10 @@ from rest_framework import serializers
|
||||
from rest_framework.reverse import reverse
|
||||
from pytz import UTC
|
||||
|
||||
from lms.djangoapps.course_home_api.mixins import VerifiedModeSerializerMixin
|
||||
from lms.djangoapps.course_home_api.serializers import ReadOnlySerializer, VerifiedModeSerializer
|
||||
|
||||
|
||||
class CourseGradeSerializer(serializers.Serializer):
|
||||
class CourseGradeSerializer(ReadOnlySerializer):
|
||||
"""
|
||||
Serializer for course grade
|
||||
"""
|
||||
@@ -19,7 +19,7 @@ class CourseGradeSerializer(serializers.Serializer):
|
||||
is_passing = serializers.BooleanField(source='passed')
|
||||
|
||||
|
||||
class SubsectionScoresSerializer(serializers.Serializer):
|
||||
class SubsectionScoresSerializer(ReadOnlySerializer):
|
||||
"""
|
||||
Serializer for subsections in section_scores
|
||||
"""
|
||||
@@ -40,6 +40,7 @@ class SubsectionScoresSerializer(serializers.Serializer):
|
||||
return str(subsection.location)
|
||||
|
||||
def get_problem_scores(self, subsection):
|
||||
"""Problem scores for this subsection"""
|
||||
problem_scores = [
|
||||
{
|
||||
'earned': score.earned,
|
||||
@@ -54,7 +55,7 @@ class SubsectionScoresSerializer(serializers.Serializer):
|
||||
Returns the URL for the subsection while taking into account if the course team has
|
||||
marked the subsection's visibility as hide after due.
|
||||
"""
|
||||
hide_url_date = (subsection.self_paced and subsection.end) or subsection.due
|
||||
hide_url_date = subsection.end if subsection.self_paced else subsection.due
|
||||
if (not self.context['staff_access'] and subsection.hide_after_due and hide_url_date
|
||||
and datetime.now(UTC) > hide_url_date):
|
||||
return None
|
||||
@@ -71,7 +72,7 @@ class SubsectionScoresSerializer(serializers.Serializer):
|
||||
return not course_blocks.get_xblock_field(subsection.location, 'contains_gated_content', False)
|
||||
|
||||
|
||||
class SectionScoresSerializer(serializers.Serializer):
|
||||
class SectionScoresSerializer(ReadOnlySerializer):
|
||||
"""
|
||||
Serializer for sections in section_scores
|
||||
"""
|
||||
@@ -79,7 +80,7 @@ class SectionScoresSerializer(serializers.Serializer):
|
||||
subsections = SubsectionScoresSerializer(source='sections', many=True)
|
||||
|
||||
|
||||
class GradingPolicySerializer(serializers.Serializer):
|
||||
class GradingPolicySerializer(ReadOnlySerializer):
|
||||
"""
|
||||
Serializer for grading policy
|
||||
"""
|
||||
@@ -96,7 +97,7 @@ class GradingPolicySerializer(serializers.Serializer):
|
||||
} for assignment_policy in grading_policy['GRADER']]
|
||||
|
||||
|
||||
class CertificateDataSerializer(serializers.Serializer):
|
||||
class CertificateDataSerializer(ReadOnlySerializer):
|
||||
"""
|
||||
Serializer for certificate data
|
||||
"""
|
||||
@@ -106,7 +107,7 @@ class CertificateDataSerializer(serializers.Serializer):
|
||||
certificate_available_date = serializers.DateTimeField()
|
||||
|
||||
|
||||
class VerificationDataSerializer(serializers.Serializer):
|
||||
class VerificationDataSerializer(ReadOnlySerializer):
|
||||
"""
|
||||
Serializer for verification data object
|
||||
"""
|
||||
@@ -115,7 +116,7 @@ class VerificationDataSerializer(serializers.Serializer):
|
||||
status_date = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ProgressTabSerializer(VerifiedModeSerializerMixin):
|
||||
class ProgressTabSerializer(VerifiedModeSerializer):
|
||||
"""
|
||||
Serializer for progress tab
|
||||
"""
|
||||
@@ -33,7 +33,7 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse('course-home-progress-tab', args=[self.course.id])
|
||||
self.url = reverse('course-home:progress-tab', args=[self.course.id])
|
||||
|
||||
@override_waffle_flag(COURSE_HOME_MICROFRONTEND_PROGRESS_TAB, active=True)
|
||||
@ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED)
|
||||
@@ -45,7 +45,7 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
assert response.data['section_scores'] is not None
|
||||
for chapter in response.data['section_scores']:
|
||||
assert chapter is not None
|
||||
assert ('settings/grading/' + str(self.course.id)) in response.data['studio_url']
|
||||
assert 'settings/grading/' + str(self.course.id) in response.data['studio_url']
|
||||
assert response.data['verification_data'] is not None
|
||||
assert response.data['verification_data']['status'] == 'none'
|
||||
if enrollment_mode == CourseMode.VERIFIED:
|
||||
@@ -74,7 +74,7 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
|
||||
@override_waffle_flag(COURSE_HOME_MICROFRONTEND_PROGRESS_TAB, active=True)
|
||||
def test_get_unknown_course(self):
|
||||
url = reverse('course-home-progress-tab', args=['course-v1:unknown+course+2T2020'])
|
||||
url = reverse('course-home:progress-tab', args=['course-v1:unknown+course+2T2020'])
|
||||
response = self.client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -108,7 +108,7 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
def test_has_scheduled_content_data(self):
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
future = now() + timedelta(days=30)
|
||||
chapter = ItemFactory(parent=self.course, category='chapter', start=future)
|
||||
ItemFactory(parent=self.course, category='chapter', start=future)
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
assert response.json()['has_scheduled_content']
|
||||
@@ -127,7 +127,7 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
@override_waffle_flag(COURSE_HOME_MICROFRONTEND_PROGRESS_TAB, active=True)
|
||||
def test_user_has_passing_grade(self):
|
||||
CourseEnrollment.enroll(self.user, self.course.id)
|
||||
self.course._grading_policy['GRADE_CUTOFFS']['Pass'] = 0
|
||||
self.course.grade_cutoffs = {'Pass': 0}
|
||||
self.update_course(self.course, self.user.id)
|
||||
response = self.client.get(self.url)
|
||||
assert response.status_code == 200
|
||||
@@ -192,12 +192,12 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
assert response.data['username'] == self.user.username
|
||||
|
||||
other_user = UserFactory()
|
||||
self.url = reverse('course-home-progress-tab-other-student', args=[self.course.id, other_user.id])
|
||||
self.url = reverse('course-home:progress-tab-other-student', args=[self.course.id, other_user.id])
|
||||
CourseEnrollment.enroll(other_user, self.course.id)
|
||||
|
||||
# users with the ccx coach role can view other students' progress pages
|
||||
with patch(
|
||||
'lms.djangoapps.course_home_api.progress.v1.views.has_ccx_coach_role',
|
||||
'lms.djangoapps.course_home_api.progress.views.has_ccx_coach_role',
|
||||
return_value=True
|
||||
):
|
||||
response = self.client.get(self.url)
|
||||
@@ -2,8 +2,8 @@
|
||||
Progress Tab Views
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.http.response import Http404
|
||||
from django.contrib.auth.models import User
|
||||
from edx_django_utils import monitoring as monitoring_utils
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
|
||||
@@ -14,14 +14,16 @@ from rest_framework.response import Response
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from lms.djangoapps.course_home_api.progress.v1.serializers import ProgressTabSerializer
|
||||
from lms.djangoapps.course_home_api.progress.serializers import ProgressTabSerializer
|
||||
from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active
|
||||
from lms.djangoapps.courseware.access import has_access, has_ccx_coach_role
|
||||
from lms.djangoapps.course_blocks.api import get_course_blocks
|
||||
from lms.djangoapps.course_blocks.transformers import start_date
|
||||
|
||||
from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
|
||||
from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary, get_course_with_access, get_studio_url
|
||||
from lms.djangoapps.courseware.courses import (
|
||||
get_course_blocks_completion_summary, get_course_with_access, get_studio_url,
|
||||
)
|
||||
from lms.djangoapps.courseware.masquerade import setup_masquerade
|
||||
from lms.djangoapps.courseware.views.views import get_cert_data
|
||||
|
||||
@@ -34,6 +36,8 @@ from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiv
|
||||
from openedx.features.content_type_gating.block_transformers import ContentTypeGateTransformer
|
||||
from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ProgressTabView(RetrieveAPIView):
|
||||
"""
|
||||
@@ -78,7 +82,8 @@ class ProgressTabView(RetrieveAPIView):
|
||||
learner_has_access: (bool) whether the learner has access to the subsection (could be FBE gated)
|
||||
num_points_earned: (int) the amount of points the user has earned for the given subsection
|
||||
num_points_possible: (int) the total amount of points possible for the given subsection
|
||||
percent_graded: (float) the percentage of total points the user has received a grade for in a given subsection
|
||||
percent_graded: (float) the percentage of total points the user has received a grade for in a given
|
||||
subsection
|
||||
problem_scores: List of objects that represent individual problem scores with the following fields:
|
||||
earned: (float) number of earned points
|
||||
possible: (float) number of possible points
|
||||
@@ -86,18 +91,19 @@ class ProgressTabView(RetrieveAPIView):
|
||||
('always', 'never', 'past_due', values defined in
|
||||
common/lib/xmodule/xmodule/modulestore/inheritance.py)
|
||||
show_grades: (bool) a bool for whether to show grades based on the access the user has
|
||||
url: (str or None) the absolute path url to the Subsection or None if the Subsection is no longer accessible
|
||||
to the learner due to a hide_after_due course team setting
|
||||
url: (str or None) the absolute path url to the Subsection or None if the Subsection is no longer
|
||||
accessible to the learner due to a hide_after_due course team setting
|
||||
enrollment_mode: (str) a str representing the enrollment the user has ('audit', 'verified', ...)
|
||||
grading_policy:
|
||||
assignment_policies: List of serialized assignment grading policy objects, each has the following fields:
|
||||
num_droppable: (int) the number of lowest scored assignments that will not be counted towards the final grade
|
||||
num_droppable: (int) the number of lowest scored assignments that will not be counted towards the final
|
||||
grade
|
||||
short_label: (str) the abbreviated name given to the assignment type
|
||||
type: (str) the assignment type
|
||||
weight: (float) the percent weight the given assigment type has on the overall grade
|
||||
grade_range: an object containing the grade range cutoffs. The exact keys in the object can vary, but they
|
||||
range from just 'Pass', to a combination of 'A', 'B', 'C', and 'D'. If a letter grade is present,
|
||||
'Pass' is not included.
|
||||
range from just 'Pass', to a combination of 'A', 'B', 'C', and 'D'. If a letter grade is
|
||||
present, 'Pass' is not included.
|
||||
studio_url: (str) a str of the link to the grading in studio for the course
|
||||
verification_data: an object containing
|
||||
link: (str) the link to either start or retry ID verification
|
||||
@@ -119,15 +125,44 @@ class ProgressTabView(RetrieveAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = ProgressTabSerializer
|
||||
|
||||
def _get_student_user(self, request, course_key, student_id, is_staff):
|
||||
"""Gets the student User object, either from coaching, masquerading, or normal actual request"""
|
||||
if student_id:
|
||||
try:
|
||||
student_id = int(student_id)
|
||||
except ValueError as e:
|
||||
raise Http404 from e
|
||||
|
||||
if student_id is None or student_id == request.user.id:
|
||||
_, student = setup_masquerade(
|
||||
request,
|
||||
course_key,
|
||||
staff_access=is_staff,
|
||||
reset_masquerade_data=True
|
||||
)
|
||||
return student
|
||||
|
||||
# When a student_id is passed in, we display the progress page for the user
|
||||
# with the provided user id, rather than the requesting user
|
||||
try:
|
||||
coach_access = has_ccx_coach_role(request.user, course_key)
|
||||
except CCXLocatorValidationException:
|
||||
coach_access = False
|
||||
|
||||
has_access_on_students_profiles = is_staff or coach_access
|
||||
# Requesting access to a different student's profile
|
||||
if not has_access_on_students_profiles:
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
return User.objects.get(id=student_id)
|
||||
except User.DoesNotExist as exc:
|
||||
raise Http404 from exc
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
course_key_string = kwargs.get('course_key_string')
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
student_id = kwargs.get('student_id')
|
||||
if student_id:
|
||||
try:
|
||||
student_id = int(student_id)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
if not course_home_mfe_progress_tab_is_active(course_key):
|
||||
raise Http404
|
||||
@@ -138,30 +173,7 @@ class ProgressTabView(RetrieveAPIView):
|
||||
monitoring_utils.set_custom_attribute('is_staff', request.user.is_staff)
|
||||
is_staff = bool(has_access(request.user, 'staff', course_key))
|
||||
|
||||
if student_id is None or student_id == request.user.id:
|
||||
_, student = setup_masquerade(
|
||||
request,
|
||||
course_key,
|
||||
staff_access=is_staff,
|
||||
reset_masquerade_data=True
|
||||
)
|
||||
else:
|
||||
# When a student_id is passed in, we display the progress page for the user
|
||||
# with the provided user id, rather than the requesting user
|
||||
try:
|
||||
coach_access = has_ccx_coach_role(request.user, course_key)
|
||||
except CCXLocatorValidationException:
|
||||
coach_access = False
|
||||
|
||||
has_access_on_students_profiles = is_staff or coach_access
|
||||
# Requesting access to a different student's profile
|
||||
if not has_access_on_students_profiles:
|
||||
raise Http404
|
||||
try:
|
||||
student = User.objects.get(id=student_id)
|
||||
except User.DoesNotExist as exc:
|
||||
raise Http404 from exc
|
||||
|
||||
student = self._get_student_user(request, course_key, student_id, is_staff)
|
||||
username = get_enterprise_learner_generic_name(request) or student.username
|
||||
|
||||
course = get_course_with_access(student, 'load', course_key, check_if_enrolled=False)
|
||||
@@ -219,7 +231,7 @@ class ProgressTabView(RetrieveAPIView):
|
||||
'completion_summary': get_course_blocks_completion_summary(course_key, student),
|
||||
'course_grade': course_grade,
|
||||
'has_scheduled_content': has_scheduled_content,
|
||||
'section_scores': course_grade.chapter_grades.values(),
|
||||
'section_scores': list(course_grade.chapter_grades.values()),
|
||||
'enrollment_mode': enrollment_mode,
|
||||
'grading_policy': grading_policy,
|
||||
'studio_url': get_studio_url(course, 'settings/grading'),
|
||||
@@ -229,7 +241,7 @@ class ProgressTabView(RetrieveAPIView):
|
||||
context['staff_access'] = is_staff
|
||||
context['course_blocks'] = course_blocks
|
||||
context['course_key'] = course_key
|
||||
# course_overview and enrollment will be used by VerifiedModeSerializerMixin
|
||||
# course_overview and enrollment will be used by VerifiedModeSerializer
|
||||
context['course_overview'] = course_overview
|
||||
context['enrollment'] = enrollment
|
||||
serializer = self.get_serializer_class()(data, context=context)
|
||||
Reference in New Issue
Block a user