Merge branch 'master' into NiedielnitsevIvan/FC-0047/feature/implement-push-notifications-chanel

This commit is contained in:
Ivan Niedielnitsev
2024-09-11 15:26:22 +03:00
committed by GitHub
45 changed files with 1153 additions and 664 deletions

View File

@@ -3515,6 +3515,14 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm
self.client.logout()
url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, self.full_test_message)
assert response.status_code == 401
def test_send_email_logged_in_but_no_perms(self):
self.client.logout()
user = UserFactory()
self.client.login(username=user.username, password=self.TEST_PASSWORD)
url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, self.full_test_message)
assert response.status_code == 403
def test_send_email_but_not_staff(self):
@@ -3635,6 +3643,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm
url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
with LogCapture() as log:
response = self.client.post(url, self.full_test_message)
assert response.status_code == 400

View File

@@ -107,7 +107,8 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, Queue
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
from lms.djangoapps.instructor_task.models import ReportStore
from lms.djangoapps.instructor.views.serializer import (
AccessSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer
AccessSerializer, RoleNameSerializer, ShowStudentExtensionSerializer,
UserSerializer, SendEmailSerializer, StudentAttemptsSerializer
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
@@ -1818,23 +1819,24 @@ class StudentProgressUrl(APIView):
return Response(serializer.data)
@transaction.non_atomic_requests
@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_course_permission(permissions.GIVE_STUDENT_EXTENSION)
@require_post_params(
problem_to_reset="problem urlname to reset"
)
@common_exceptions_400
def reset_student_attempts(request, course_id):
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class ResetStudentAttempts(DeveloperErrorViewMixin, APIView):
"""
Resets a students attempts counter or starts a task to reset all students
attempts counters. Optionally deletes student state for a problem. Limited
to staff access. Some sub-methods limited to instructor access.
"""
http_method_names = ['post']
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = permissions.GIVE_STUDENT_EXTENSION
serializer_class = StudentAttemptsSerializer
Takes some of the following query parameters
@method_decorator(ensure_csrf_cookie)
@transaction.non_atomic_requests
def post(self, request, course_id):
"""
Takes some of the following query parameters
- problem_to_reset is a urlname of a problem
- unique_student_identifier is an email or username
- all_students is a boolean
@@ -1844,65 +1846,74 @@ def reset_student_attempts(request, course_id):
- delete_module is a boolean
requires instructor access
mutually exclusive with all_students
"""
course_id = CourseKey.from_string(course_id)
course = get_course_with_access(
request.user, 'staff', course_id, depth=None
)
all_students = _get_boolean_param(request, 'all_students')
"""
course_id = CourseKey.from_string(course_id)
serializer_data = self.serializer_class(data=request.data)
if all_students and not has_access(request.user, 'instructor', course):
return HttpResponseForbidden("Requires instructor access.")
if not serializer_data.is_valid():
return HttpResponseBadRequest(reason=serializer_data.errors)
problem_to_reset = strip_if_string(request.POST.get('problem_to_reset'))
student_identifier = request.POST.get('unique_student_identifier', None)
student = None
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
delete_module = _get_boolean_param(request, 'delete_module')
# parameter combinations
if all_students and student:
return HttpResponseBadRequest(
"all_students and unique_student_identifier are mutually exclusive."
)
if all_students and delete_module:
return HttpResponseBadRequest(
"all_students and delete_module are mutually exclusive."
course = get_course_with_access(
request.user, 'staff', course_id, depth=None
)
try:
module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id)
except InvalidKeyError:
return HttpResponseBadRequest()
all_students = serializer_data.validated_data.get('all_students')
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
if all_students and not has_access(request.user, 'instructor', course):
return HttpResponseForbidden("Requires instructor access.")
if student:
try:
enrollment.reset_student_attempts(
course_id,
student,
module_state_key,
requesting_user=request.user,
delete_module=delete_module
problem_to_reset = strip_if_string(serializer_data.validated_data.get('problem_to_reset'))
student_identifier = request.POST.get('unique_student_identifier', None)
student = serializer_data.validated_data.get('unique_student_identifier')
delete_module = serializer_data.validated_data.get('delete_module')
# parameter combinations
if all_students and student:
return HttpResponseBadRequest(
"all_students and unique_student_identifier are mutually exclusive."
)
if all_students and delete_module:
return HttpResponseBadRequest(
"all_students and delete_module are mutually exclusive."
)
except StudentModule.DoesNotExist:
return HttpResponseBadRequest(_("Module does not exist."))
except sub_api.SubmissionError:
# Trust the submissions API to log the error
error_msg = _("An error occurred while deleting the score.")
return HttpResponse(error_msg, status=500)
response_payload['student'] = student_identifier
elif all_students:
task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
response_payload['task'] = TASK_SUBMISSION_OK
response_payload['student'] = 'All Students'
else:
return HttpResponseBadRequest()
return JsonResponse(response_payload)
try:
module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id)
except InvalidKeyError:
return HttpResponseBadRequest()
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
if student:
try:
enrollment.reset_student_attempts(
course_id,
student,
module_state_key,
requesting_user=request.user,
delete_module=delete_module
)
except StudentModule.DoesNotExist:
return HttpResponseBadRequest(_("Module does not exist."))
except sub_api.SubmissionError:
# Trust the submissions API to log the error
error_msg = _("An error occurred while deleting the score.")
return HttpResponse(error_msg, status=500)
response_payload['student'] = student_identifier
elif all_students:
try:
task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
response_payload['task'] = TASK_SUBMISSION_OK
response_payload['student'] = 'All Students'
except Exception: # pylint: disable=broad-except
error_msg = _("An error occurred while attempting to reset for all students.")
return HttpResponse(error_msg, status=500)
else:
return HttpResponseBadRequest()
return JsonResponse(response_payload)
@transaction.non_atomic_requests
@@ -1939,8 +1950,10 @@ def reset_student_attempts_for_entrance_exam(request, course_id):
student_identifier = request.POST.get('unique_student_identifier', None)
student = None
if student_identifier is not None:
student = get_student_from_identifier(student_identifier)
all_students = _get_boolean_param(request, 'all_students')
delete_module = _get_boolean_param(request, 'delete_module')
@@ -2542,16 +2555,22 @@ class ReportDownloads(DeveloperErrorViewMixin, APIView):
return _list_report_downloads(request=request, course_id=course_id)
@require_POST
@ensure_csrf_cookie
def list_report_downloads(request, course_id):
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
class ListReportDownloads(APIView):
"""
List grade CSV files that are available for download for this course.
Takes the following query parameters:
- (optional) report_name - name of the report
"""
return _list_report_downloads(request=request, course_id=course_id)
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = permissions.CAN_RESEARCH
@method_decorator(ensure_csrf_cookie)
def post(self, request, course_id):
return _list_report_downloads(request=request, course_id=course_id)
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@@ -2765,81 +2784,96 @@ def list_forum_members(request, course_id):
return JsonResponse(response_payload)
@transaction.non_atomic_requests
@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_course_permission(permissions.EMAIL)
@require_post_params(send_to="sending to whom", subject="subject line", message="message text")
@common_exceptions_400
def send_email(request, course_id):
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class SendEmail(DeveloperErrorViewMixin, APIView):
"""
Send an email to self, staff, cohorts, or everyone involved in a course.
Query Parameters:
- 'send_to' specifies what group the email should be sent to
Options are defined by the CourseEmail model in
lms/djangoapps/bulk_email/models.py
- 'subject' specifies email's subject
- 'message' specifies email's content
"""
course_id = CourseKey.from_string(course_id)
course_overview = CourseOverview.get_from_id(course_id)
http_method_names = ['post']
permission_classes = (IsAuthenticated, permissions.InstructorPermission)
permission_name = permissions.EMAIL
serializer_class = SendEmailSerializer
if not is_bulk_email_feature_enabled(course_id):
log.warning(f"Email is not enabled for course {course_id}")
return HttpResponseForbidden("Email is not enabled for this course.")
@method_decorator(ensure_csrf_cookie)
@method_decorator(transaction.non_atomic_requests)
def post(self, request, course_id):
"""
Query Parameters:
- 'send_to' specifies what group the email should be sent to
Options are defined by the CourseEmail model in
lms/djangoapps/bulk_email/models.py
- 'subject' specifies email's subject
- 'message' specifies email's content
"""
course_id = CourseKey.from_string(course_id)
course_overview = CourseOverview.get_from_id(course_id)
targets = json.loads(request.POST.get("send_to"))
subject = request.POST.get("subject")
message = request.POST.get("message")
# optional, this is a date and time in the form of an ISO8601 string
schedule = request.POST.get("schedule", "")
if not is_bulk_email_feature_enabled(course_id):
log.warning(f"Email is not enabled for course {course_id}")
return HttpResponseForbidden("Email is not enabled for this course.")
schedule_dt = None
if schedule:
serializer_data = self.serializer_class(data=request.data)
if not serializer_data.is_valid():
return HttpResponseBadRequest(reason=serializer_data.errors)
# Skipping serializer validation to avoid potential disruptions.
# The API handles numerous input variations, and changes here could introduce breaking issues.
targets = json.loads(request.POST.get("send_to"))
subject = serializer_data.validated_data.get("subject")
message = serializer_data.validated_data.get("message")
# optional, this is a date and time in the form of an ISO8601 string
schedule = serializer_data.validated_data.get("schedule", "")
schedule_dt = None
if schedule:
try:
# convert the schedule from a string to a datetime, then check if its a
# valid future date and time, dateutil
# will throw a ValueError if the schedule is no good.
schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc)
if schedule_dt < datetime.datetime.now(pytz.utc):
raise ValueError("the requested schedule is in the past")
except ValueError as value_error:
error_message = (
f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: "
f"{value_error}"
)
log.error(error_message)
return HttpResponseBadRequest(error_message)
# Retrieve the customized email "from address" and email template from site configuration for the c
# ourse/partner.
# If there is no site configuration enabled for the current site then we use system defaults for both.
from_addr = _get_branded_email_from_address(course_overview)
template_name = _get_branded_email_template(course_overview)
# Create the CourseEmail object. This is saved immediately so that any transaction that has been
# pending up to this point will also be committed.
try:
# convert the schedule from a string to a datetime, then check if its a valid future date and time, dateutil
# will throw a ValueError if the schedule is no good.
schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc)
if schedule_dt < datetime.datetime.now(pytz.utc):
raise ValueError("the requested schedule is in the past")
except ValueError as value_error:
error_message = (
f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: "
f"{value_error}"
email = create_course_email(
course_id,
request.user,
targets,
subject,
message,
template_name=template_name,
from_addr=from_addr,
)
log.error(error_message)
return HttpResponseBadRequest(error_message)
except ValueError as err:
return HttpResponseBadRequest(repr(err))
# Retrieve the customized email "from address" and email template from site configuration for the course/partner. If
# there is no site configuration enabled for the current site then we use system defaults for both.
from_addr = _get_branded_email_from_address(course_overview)
template_name = _get_branded_email_template(course_overview)
# Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt)
# Create the CourseEmail object. This is saved immediately so that any transaction that has been pending up to this
# point will also be committed.
try:
email = create_course_email(
course_id,
request.user,
targets,
subject,
message,
template_name=template_name,
from_addr=from_addr,
)
except ValueError as err:
return HttpResponseBadRequest(repr(err))
response_payload = {
'course_id': str(course_id),
'success': True,
}
# Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt)
response_payload = {
'course_id': str(course_id),
'success': True,
}
return JsonResponse(response_payload)
return JsonResponse(response_payload)
@require_POST

View File

@@ -34,7 +34,7 @@ urlpatterns = [
path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'),
path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"),
path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'),
path('reset_student_attempts', api.reset_student_attempts, name='reset_student_attempts'),
path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'),
path('rescore_problem', api.rescore_problem, name='rescore_problem'),
path('override_problem_score', api.override_problem_score, name='override_problem_score'),
path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam,
@@ -49,7 +49,7 @@ urlpatterns = [
path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'),
path('list_forum_members', api.list_forum_members, name='list_forum_members'),
path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'),
path('send_email', api.send_email, name='send_email'),
path('send_email', api.SendEmail.as_view(), name='send_email'),
path('change_due_date', api.change_due_date, name='change_due_date'),
path('reset_due_date', api.reset_due_date, name='reset_due_date'),
path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'),
@@ -59,7 +59,7 @@ urlpatterns = [
path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'),
# Grade downloads...
path('list_report_downloads', api.list_report_downloads, name='list_report_downloads'),
path('list_report_downloads', api.ListReportDownloads.as_view(), name='list_report_downloads'),
path('calculate_grades_csv', api.calculate_grades_csv, name='calculate_grades_csv'),
path('problem_grade_report', api.problem_grade_report, name='problem_grade_report'),

View File

@@ -77,3 +77,75 @@ class ShowStudentExtensionSerializer(serializers.Serializer):
return None
return user
class StudentAttemptsSerializer(serializers.Serializer):
"""
Serializer for resetting a students attempts counter or starts a task to reset all students
attempts counters.
"""
problem_to_reset = serializers.CharField(
help_text="The identifier or description of the problem that needs to be reset."
)
# following are optional params.
unique_student_identifier = serializers.CharField(
help_text="Email or username of student.", required=False
)
all_students = serializers.CharField(required=False)
delete_module = serializers.CharField(required=False)
def validate_all_students(self, value):
"""
converts the all_student params value to bool.
"""
return self.verify_bool(value)
def validate_delete_module(self, value):
"""
converts the all_student params value.
"""
return self.verify_bool(value)
def validate_unique_student_identifier(self, value):
"""
Validate that the student corresponds to an existing user.
"""
try:
user = get_student_from_identifier(value)
except User.DoesNotExist:
return None
return user
def verify_bool(self, value):
"""
Returns the value of the boolean parameter with the given
name in the POST request. Handles translation from string
values to boolean values.
"""
if value is not None:
return value in ['true', 'True', True]
return False
class SendEmailSerializer(serializers.Serializer):
"""
Serializer for sending an email with optional scheduling.
Fields:
send_to (str): The email address of the recipient. This field is required.
subject (str): The subject line of the email. This field is required.
message (str): The body of the email. This field is required.
schedule (str, optional):
An optional field to specify when the email should be sent.
If provided, this should be a string that can be parsed into a
datetime format or some other scheduling logic.
"""
send_to = serializers.CharField(write_only=True, required=True)
# set max length as per model field.
subject = serializers.CharField(max_length=128, write_only=True, required=True)
message = serializers.CharField(required=True)
schedule = serializers.CharField(required=False)