Merge pull request #30408 from openedx/jhynes/microba-1510_update-email-api
feat: MICROBA-1510; add support for updating course email content and schedules
This commit is contained in:
@@ -6,18 +6,17 @@ already been submitted, filtered either by running state or input
|
||||
arguments.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
from collections import Counter
|
||||
|
||||
import dateutil
|
||||
import pytz
|
||||
from celery.states import READY_STATES
|
||||
|
||||
from common.djangoapps.util import milestones_helpers
|
||||
from lms.djangoapps.bulk_email.models import CourseEmail
|
||||
from lms.djangoapps.bulk_email.api import get_course_email
|
||||
from lms.djangoapps.certificates.models import CertificateGenerationHistory
|
||||
from lms.djangoapps.instructor_task.api_helper import (
|
||||
QueueConnectionError,
|
||||
@@ -52,6 +51,7 @@ from lms.djangoapps.instructor_task.tasks import (
|
||||
send_bulk_course_email,
|
||||
generate_anonymous_ids_for_course
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -315,7 +315,7 @@ def submit_bulk_course_email(request, course_key, email_id, schedule=None):
|
||||
# appropriate access to the course. But make sure that the email exists.
|
||||
# We also pull out the targets argument here, so that is displayed in
|
||||
# the InstructorTask status.
|
||||
email_obj = CourseEmail.objects.get(id=email_id)
|
||||
email_obj = get_course_email(email_id)
|
||||
# task_input has a limit to the size it can store, so any target_type with count > 1 is combined and counted
|
||||
targets = Counter([target.target_type for target in email_obj.targets.all()])
|
||||
targets = [
|
||||
@@ -588,3 +588,37 @@ def process_scheduled_tasks():
|
||||
submit_scheduled_task(schedule)
|
||||
except QueueConnectionError as exc:
|
||||
log.error(f"Error processing scheduled task with task id '{schedule.task.id}': {exc}")
|
||||
|
||||
|
||||
def convert_schedule_to_utc_from_local(schedule, browser_timezone, user):
|
||||
"""
|
||||
Utility function to help convert the schedule of an instructor task from the requesters local time and timezone
|
||||
(taken from the request) to a UTC datetime.
|
||||
|
||||
Args:
|
||||
schedule (String): The desired time to execute a scheduled task, in local time, in the form of an ISO8601
|
||||
string.
|
||||
timezone (String): The time zone, as captured by the user's web browser, in the form of a string.
|
||||
user (User): The user requesting the action, captured from the originating web request. Used to lookup the
|
||||
the time zone preference as set in the user's account settings.
|
||||
|
||||
Returns:
|
||||
DateTime: A datetime instance describing when to execute this schedule task converted to the UTC timezone.
|
||||
"""
|
||||
# look up the requesting user's timezone from their account settings
|
||||
preferred_timezone = get_user_preference(user, 'time_zone', username=user.username)
|
||||
# use the user's preferred timezone (if available), otherwise use the browser timezone.
|
||||
timezone = preferred_timezone if preferred_timezone else browser_timezone
|
||||
|
||||
# convert the schedule to UTC
|
||||
log.info(f"Converting requested schedule from local time '{schedule}' with the timezone '{timezone}' to UTC")
|
||||
|
||||
local_tz = dateutil.tz.gettz(timezone)
|
||||
if local_tz is None:
|
||||
raise ValueError(
|
||||
"Unable to determine the time zone to use to convert the schedule to UTC"
|
||||
)
|
||||
local_dt = dateutil.parser.parse(schedule).replace(tzinfo=local_tz)
|
||||
schedule_utc = local_dt.astimezone(pytz.utc)
|
||||
|
||||
return schedule_utc
|
||||
|
||||
12
lms/djangoapps/instructor_task/rest_api/v1/exceptions.py
Normal file
12
lms/djangoapps/instructor_task/rest_api/v1/exceptions.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Exception classes for Instructor Task REST APIs.
|
||||
"""
|
||||
|
||||
|
||||
class TaskUpdateException(Exception):
|
||||
"""
|
||||
An exception that occurs when trying to update an instructor task instance. This covers scenarios where an updated
|
||||
task schedule is not valid, or we are trying to update a task that has already been processed (is no longer in the
|
||||
`SCHEDULED` state).
|
||||
"""
|
||||
pass # pylint: disable=unnecessary-pass
|
||||
@@ -6,7 +6,7 @@ from rest_framework import permissions
|
||||
from common.djangoapps.student.api import is_user_staff_or_instructor_in_course
|
||||
|
||||
|
||||
class CanViewOrDeleteScheduledBulkCourseEmails(permissions.BasePermission):
|
||||
class CanViewOrModifyScheduledBulkCourseEmailTasks(permissions.BasePermission):
|
||||
"""
|
||||
Permission class that ensures a user is allowed to interact with the bulk course messages in a given course-run.
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Tests for the instructor_task app's REST API v1 views.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
from uuid import uuid4
|
||||
import pytz
|
||||
|
||||
from celery.states import REVOKED
|
||||
import ddt
|
||||
@@ -17,11 +19,12 @@ from common.djangoapps.student.tests.factories import (
|
||||
StaffFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from lms.djangoapps.bulk_email.api import create_course_email
|
||||
from lms.djangoapps.bulk_email.api import create_course_email, get_course_email
|
||||
from lms.djangoapps.bulk_email.data import BulkEmailTargetChoices
|
||||
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
|
||||
from lms.djangoapps.instructor_task.models import InstructorTask, PROGRESS, SCHEDULED
|
||||
from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule, PROGRESS, SCHEDULED
|
||||
from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory, InstructorTaskScheduleFactory
|
||||
from openedx.core.lib.html_to_text import html_to_text
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -105,6 +108,13 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase):
|
||||
task_args=json.dumps(task_args),
|
||||
)
|
||||
|
||||
def _assert_error_code_and_message(self, response, expected_error_code, expected_error_msg):
|
||||
"""
|
||||
Utility function to verify expected error code and messages from a response.
|
||||
"""
|
||||
assert response.status_code == expected_error_code
|
||||
assert response.data.get("detail") == expected_error_msg
|
||||
|
||||
@ddt.data(
|
||||
("globalstaff", 200),
|
||||
("instructor_course1", 200),
|
||||
@@ -151,7 +161,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase):
|
||||
|
||||
def test_delete_schedules(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the DeleteScheduledBulkEmailInstructorTask view.
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test verifies that, when a task schedule is cancelled, the data is in the correct state and that the task
|
||||
is no longer returned with GET requests.
|
||||
@@ -182,10 +192,198 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase):
|
||||
|
||||
def test_delete_schedules_schedule_does_not_exist(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the DeleteScheduledBulkEmailInstructorTask view.
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test verifies the response received when we try to delete a task schedule that does not exist.
|
||||
"""
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.delete(f"{self._build_api_url(self.course1.id)}123456789")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_delete_schedule_task_already_processed(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test verifies the response received when we try to delete a schedule for a task that has already been
|
||||
processed by Celery.
|
||||
"""
|
||||
self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, PROGRESS, 1)
|
||||
task = InstructorTask.objects.get(course_id=self.course1.id)
|
||||
schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.delete(f"{self._build_api_url(self.course1.id)}{schedule.id}")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_update_schedule_new_date(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test verifies the ability to update the date and time of a schedule.
|
||||
"""
|
||||
self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, SCHEDULED, 1)
|
||||
task = InstructorTask.objects.get(course_id=self.course1.id)
|
||||
task_schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
schedule_datetime = datetime.datetime(3000, 6, 1, 17, 15, 0, tzinfo=pytz.utc)
|
||||
data = {
|
||||
"schedule": schedule_datetime.strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
"browser_timezone": "UTC"
|
||||
}
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.patch(
|
||||
f"{self._build_api_url(self.course1.id)}{task_schedule.id}",
|
||||
data=json.dumps(data),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
updated_schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
assert response.status_code == 200
|
||||
assert updated_schedule.task_due == schedule_datetime
|
||||
|
||||
def test_update_schedule_edit_email(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test verifies the ability to modify the contents of an email after it has been scheduled.
|
||||
"""
|
||||
self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, SCHEDULED, 1)
|
||||
task = InstructorTask.objects.get(course_id=self.course1.id)
|
||||
task_schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
email_id = json.loads(task.task_input).get("email_id")
|
||||
data = {
|
||||
"email": {
|
||||
"id": email_id,
|
||||
"targets": ["myself", "staff"],
|
||||
"subject": "UpdatedSubject",
|
||||
"message": "<p>UpdatedMessage!!!</p>"
|
||||
}
|
||||
}
|
||||
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.patch(
|
||||
f"{self._build_api_url(self.course1.id)}{task_schedule.id}",
|
||||
data=json.dumps(data),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
course_email = get_course_email(email_id)
|
||||
targets_list = course_email.targets.values_list("target_type", flat=True)
|
||||
assert response.status_code == 200
|
||||
assert course_email.subject == data.get("email").get("subject")
|
||||
assert course_email.html_message == data.get("email").get("message")
|
||||
assert course_email.text_message == html_to_text(data.get("email").get("message"))
|
||||
assert len(targets_list) == 2
|
||||
assert targets_list[0] == BulkEmailTargetChoices.SEND_TO_MYSELF
|
||||
assert targets_list[1] == BulkEmailTargetChoices.SEND_TO_STAFF
|
||||
|
||||
def test_update_schedule_bad_date(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test ensures that we don't accept dates in the past when updating a task schedule.
|
||||
"""
|
||||
self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, SCHEDULED, 1)
|
||||
task = InstructorTask.objects.get(course_id=self.course1.id)
|
||||
task_schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
data = {
|
||||
"schedule": "1999-05-10T17:15:00.000Z",
|
||||
"browser_timezone": "UTC"
|
||||
}
|
||||
expected_error_msg = (
|
||||
f"Cannot update instructor task schedule '{task_schedule.id}', the updated schedule occurs in the past"
|
||||
)
|
||||
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.patch(
|
||||
f"{self._build_api_url(self.course1.id)}{task_schedule.id}",
|
||||
data=json.dumps(data),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
self._assert_error_code_and_message(response, 400, expected_error_msg)
|
||||
|
||||
def test_update_schedule_bad_email_target_data(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test verifies error handling by simulating bad email data (bad target data).
|
||||
"""
|
||||
self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, SCHEDULED, 1)
|
||||
task = InstructorTask.objects.get(course_id=self.course1.id)
|
||||
task_schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
email_id = json.loads(task.task_input).get("email_id")
|
||||
data = {
|
||||
"email": {
|
||||
"id": email_id,
|
||||
"targets": ["pizzasteve"],
|
||||
"subject": f"Test Subject{email_id}",
|
||||
"message": "<p>Test message.</p>"
|
||||
}
|
||||
}
|
||||
expected_error_msg = (
|
||||
f"Course email being sent to an unrecognized target: 'pizzasteve' for '{self.course1.id}', subject 'Test "
|
||||
f"Subject{email_id}'"
|
||||
)
|
||||
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.patch(
|
||||
f"{self._build_api_url(self.course1.id)}{task_schedule.id}",
|
||||
data=json.dumps(data),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
self._assert_error_code_and_message(response, 400, expected_error_msg)
|
||||
|
||||
def test_update_schedule_mismatched_email_id(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test ensures that we will not update an email if it is not associated with the correct task/schedule.
|
||||
"""
|
||||
self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, SCHEDULED, 1)
|
||||
task = InstructorTask.objects.get(course_id=self.course1.id)
|
||||
task_schedule = InstructorTaskSchedule.objects.get(task=task)
|
||||
data = {
|
||||
"email": {
|
||||
"id": 8663863102,
|
||||
"targets": ["myself"],
|
||||
"subject": "Test Subject!",
|
||||
"message": "<p>Test message.</p>"
|
||||
}
|
||||
}
|
||||
expected_error_msg = (
|
||||
f"Cannot update instructor task '{task.id} for course '{self.course1.id}', the email id '8663863102' "
|
||||
"specified in the request does not match the email id associated with the task"
|
||||
)
|
||||
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.patch(
|
||||
f"{self._build_api_url(self.course1.id)}{task_schedule.id}",
|
||||
data=json.dumps(data),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
self._assert_error_code_and_message(response, 400, expected_error_msg)
|
||||
|
||||
def test_update_schedule_dne(self):
|
||||
"""
|
||||
Test case that verifies the functionality of the ModifyScheduledBulkEmailInstructorTask view.
|
||||
|
||||
This test ensures behavior when trying to update a task schedule that does not exist.
|
||||
"""
|
||||
data = {
|
||||
"schedule": "3000-05-10T17:15:00.000Z",
|
||||
"browser_timezone": "UTC"
|
||||
}
|
||||
|
||||
self.client.login(username=self.instructor_course1.username, password="test")
|
||||
response = self.client.patch(
|
||||
f"{self._build_api_url(self.course1.id)}8675309",
|
||||
data=json.dumps(data),
|
||||
content_type="application/json"
|
||||
)
|
||||
expected_error_msg = (
|
||||
"Cannot update instructor task schedule '8675309', a schedule with this ID does not exist"
|
||||
)
|
||||
|
||||
self._assert_error_code_and_message(response, 404, expected_error_msg)
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.urls import re_path
|
||||
|
||||
from lms.djangoapps.instructor_task.rest_api.v1.views import (
|
||||
ListScheduledBulkEmailInstructorTasks,
|
||||
DeleteScheduledBulkEmailInstructorTask
|
||||
ModifyScheduledBulkEmailInstructorTask
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ urlpatterns = [
|
||||
),
|
||||
re_path(
|
||||
fr"schedules/{settings.COURSE_ID_PATTERN}/bulk_email/(?P<schedule_id>[0-9]+)$",
|
||||
DeleteScheduledBulkEmailInstructorTask.as_view(),
|
||||
name="delete-scheduled-bulk-email-messages"
|
||||
ModifyScheduledBulkEmailInstructorTask.as_view(),
|
||||
name="modify-scheduled-bulk-email-messages"
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"""
|
||||
Instructor Task Django app REST API views.
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
from celery.states import REVOKED
|
||||
from django.db import transaction
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import generics, status
|
||||
|
||||
from lms.djangoapps.bulk_email.api import update_course_email
|
||||
from lms.djangoapps.instructor_task.api import convert_schedule_to_utc_from_local
|
||||
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
|
||||
from lms.djangoapps.instructor_task.models import InstructorTaskSchedule, SCHEDULED
|
||||
from lms.djangoapps.instructor_task.rest_api.v1.exceptions import TaskUpdateException
|
||||
from lms.djangoapps.instructor_task.rest_api.v1.serializers import ScheduledBulkEmailSerializer
|
||||
from lms.djangoapps.instructor_task.rest_api.v1.permissions import CanViewOrDeleteScheduledBulkCourseEmails
|
||||
from lms.djangoapps.instructor_task.rest_api.v1.permissions import CanViewOrModifyScheduledBulkCourseEmailTasks
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,7 +40,7 @@ class ListScheduledBulkEmailInstructorTasks(generics.ListAPIView):
|
||||
SessionAuthentication,
|
||||
)
|
||||
permission_classes = (
|
||||
CanViewOrDeleteScheduledBulkCourseEmails,
|
||||
CanViewOrModifyScheduledBulkCourseEmailTasks,
|
||||
)
|
||||
serializer_class = ScheduledBulkEmailSerializer
|
||||
|
||||
@@ -47,26 +54,34 @@ class ListScheduledBulkEmailInstructorTasks(generics.ListAPIView):
|
||||
.filter(task__course_id=course_id)
|
||||
.filter(task__task_state=SCHEDULED)
|
||||
.filter(task__task_type=InstructorTaskTypes.BULK_COURSE_EMAIL)
|
||||
.order_by('id')
|
||||
)
|
||||
|
||||
|
||||
class DeleteScheduledBulkEmailInstructorTask(generics.DestroyAPIView):
|
||||
class ModifyScheduledBulkEmailInstructorTask(generics.DestroyAPIView, generics.UpdateAPIView):
|
||||
"""
|
||||
A view that deletes an instructor task schedule instance and revokes the associated instructor task.
|
||||
A view that supports the modification of instructor task schedules. It provides the ability to:
|
||||
* Delete an instructor task schedule
|
||||
* Update an instructor task schedule and/or update the course email associated with the scheduled task.
|
||||
|
||||
Path: DELETE `api/instructor_task/v1/schedules/{course_id}/bulk_email/{task_schedule_id}`
|
||||
|
||||
Path: DELETE or PATCH `api/instructor_task/v1/schedules/{course_id}/bulk_email/{task_schedule_id}`
|
||||
|
||||
Returns:
|
||||
* 200: The schedule or email content was updated successfully.
|
||||
* 204: No Content - Deleting the schedule was successful.
|
||||
* 404: Requested schedule object could not be found and thus could not be deleted.
|
||||
* 400: Bad request - Updating the email content or schedule failed due to bad data in the request.
|
||||
* 403: User does not have permission to modify the object specified.
|
||||
* 404: Requested schedule object could not be found and thus could not be modified or removed.
|
||||
"""
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
SessionAuthentication,
|
||||
)
|
||||
permission_classes = (
|
||||
CanViewOrDeleteScheduledBulkCourseEmails,
|
||||
CanViewOrModifyScheduledBulkCourseEmailTasks,
|
||||
)
|
||||
serializer_class = ScheduledBulkEmailSerializer
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
course_id = kwargs["course_id"]
|
||||
@@ -78,11 +93,96 @@ class DeleteScheduledBulkEmailInstructorTask(generics.DestroyAPIView):
|
||||
except InstructorTaskSchedule.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# update the task's status to REVOKED and then delete the task schedule instance
|
||||
task = schedule.task
|
||||
# verify the task hasn't already been processed before revoking
|
||||
try:
|
||||
self._verify_task_state(task)
|
||||
except TaskUpdateException as task_update_err:
|
||||
return Response(
|
||||
{"detail": str(task_update_err)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# update the task's status to REVOKED and then delete the task schedule instance
|
||||
log.info(f"Revoking instructor task with id '{task.id}' for course '{task.course_id}'")
|
||||
task.task_state = REVOKED
|
||||
task.save()
|
||||
schedule.delete()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
course_id = kwargs["course_id"]
|
||||
schedule_id = kwargs["schedule_id"]
|
||||
|
||||
# extract schedule and email data from the request
|
||||
schedule = request.data.get("schedule", None)
|
||||
email_data = request.data.get("email", None)
|
||||
|
||||
try:
|
||||
task_schedule = InstructorTaskSchedule.objects.get(id=schedule_id)
|
||||
task = task_schedule.task
|
||||
self._verify_task_state(task)
|
||||
|
||||
with transaction.atomic():
|
||||
if schedule:
|
||||
browser_timezone = request.data.get("browser_timezone", None)
|
||||
schedule_utc = convert_schedule_to_utc_from_local(schedule, browser_timezone, request.user)
|
||||
self._verify_valid_schedule(schedule_id, schedule_utc)
|
||||
task_schedule.task_due = schedule_utc
|
||||
task_schedule.save()
|
||||
if email_data:
|
||||
email_id = email_data.get("id")
|
||||
self._verify_task_and_email_associated(task, email_id)
|
||||
targets = email_data.get("targets")
|
||||
subject = email_data.get("subject")
|
||||
message = email_data.get("message")
|
||||
update_course_email(course_id, email_id, targets, subject, message)
|
||||
except (TaskUpdateException, ValueError) as task_update_err:
|
||||
return Response(
|
||||
{"detail": str(task_update_err)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except InstructorTaskSchedule.DoesNotExist:
|
||||
error_message = (
|
||||
f"Cannot update instructor task schedule '{schedule_id}', a schedule with this ID does not exist"
|
||||
)
|
||||
return Response(
|
||||
{"detail": error_message},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
return Response(ScheduledBulkEmailSerializer(instance=task_schedule).data)
|
||||
|
||||
def _verify_valid_schedule(self, schedule_id, schedule):
|
||||
"""
|
||||
Verifies that the updated schedule data for the task is valid. We check to make sure that the date or time
|
||||
requested is not in the past.
|
||||
"""
|
||||
now = datetime.datetime.now(pytz.utc)
|
||||
if schedule < now:
|
||||
raise TaskUpdateException(
|
||||
f"Cannot update instructor task schedule '{schedule_id}', the updated schedule occurs in the past"
|
||||
)
|
||||
|
||||
def _verify_task_state(self, task):
|
||||
"""
|
||||
Verifies that the task is still in the `SCHEDULED` state. If we have already started (or finished) processing
|
||||
the task then there is no need to update it.
|
||||
"""
|
||||
if task.task_state != SCHEDULED:
|
||||
raise TaskUpdateException(
|
||||
f"Cannot update instructor task '{task.id}' for course '{task.course_id}', this task has already been "
|
||||
"processed"
|
||||
)
|
||||
|
||||
def _verify_task_and_email_associated(self, task, email_id):
|
||||
"""
|
||||
Verifies that the email we are trying to update is actually associated with the task in question.
|
||||
"""
|
||||
email_id_from_task = json.loads(task.task_input).get("email_id", None)
|
||||
if email_id_from_task != email_id:
|
||||
raise TaskUpdateException(
|
||||
f"Cannot update instructor task '{task.id} for course '{task.course_id}', the email id '{email_id}' "
|
||||
f"specified in the request does not match the email id associated with the task"
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import dateutil
|
||||
import pytest
|
||||
import pytz
|
||||
import ddt
|
||||
@@ -21,6 +22,7 @@ from lms.djangoapps.bulk_email.data import BulkEmailTargetChoices
|
||||
from lms.djangoapps.certificates.data import CertificateStatuses
|
||||
from lms.djangoapps.certificates.models import CertificateGenerationHistory
|
||||
from lms.djangoapps.instructor_task.api import (
|
||||
convert_schedule_to_utc_from_local,
|
||||
SpecificStudentIdMissingError,
|
||||
generate_anonymous_ids,
|
||||
generate_certificates_for_students,
|
||||
@@ -61,6 +63,7 @@ from lms.djangoapps.instructor_task.tests.test_base import (
|
||||
InstructorTaskTestCase,
|
||||
TestReportMixin
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference, delete_user_preference
|
||||
|
||||
LOG_PATH = 'lms.djangoapps.instructor_task.api'
|
||||
|
||||
@@ -528,3 +531,77 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
|
||||
process_scheduled_tasks()
|
||||
|
||||
log.check_present((LOG_PATH, "ERROR", expected_messages[0]),)
|
||||
|
||||
|
||||
@patch('lms.djangoapps.bulk_email.models.html_to_text', Mock(return_value='Mocking CourseEmail.text_message', autospec=True)) # lint-amnesty, pylint: disable=line-too-long
|
||||
class ScheduledInstructorTaskTests(TestReportMixin, InstructorTaskCourseTestCase):
|
||||
"""
|
||||
Tests API methods that support scheduled instructor tasks
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.instructor = UserFactory.create(username="instructor", email="instructor@edx.org")
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
delete_user_preference(self.instructor, 'time_zone', self.instructor.username)
|
||||
|
||||
def _get_expected_schedule(self, schedule, timezone):
|
||||
local_tz = dateutil.tz.gettz(timezone)
|
||||
local_dt = dateutil.parser.parse(schedule).replace(tzinfo=local_tz)
|
||||
return local_dt.astimezone(pytz.utc)
|
||||
|
||||
def test_convert_schedule_to_utc_from_local_no_user_preference(self):
|
||||
"""
|
||||
A test that verifies the behavior of the `convert_schedule_to_utc_from_local` function. Verifies that we use
|
||||
the browser provided timezone data if there is no preferred timezone set by the user in their account settings.
|
||||
"""
|
||||
schedule = "2099-05-02T14:00:00.000Z"
|
||||
browser_timezone = "America/New_York"
|
||||
expected_schedule = self._get_expected_schedule(schedule, browser_timezone)
|
||||
|
||||
schedule_utc = convert_schedule_to_utc_from_local(schedule, browser_timezone, self.instructor)
|
||||
|
||||
assert schedule_utc == expected_schedule
|
||||
|
||||
def test_convert_schedule_to_utc_from_local_with_preferred_timezone(self):
|
||||
"""
|
||||
A test that verifies the behavior of the `convert_schedule_to_utc_from_local` function. Verifies that we use the
|
||||
preferred timezone if a user has set one in their account settings.
|
||||
"""
|
||||
schedule = "2099-05-02T14:00:00.000Z"
|
||||
preferred_timezone = "America/Anchorage"
|
||||
set_user_preference(self.instructor, 'time_zone', preferred_timezone)
|
||||
expected_schedule = self._get_expected_schedule(schedule, preferred_timezone)
|
||||
|
||||
schedule_utc = convert_schedule_to_utc_from_local(schedule, "", self.instructor)
|
||||
|
||||
assert schedule_utc == expected_schedule
|
||||
|
||||
def test_convert_schedule_to_utc_from_local_with_preferred_and_browser_timezone(self):
|
||||
"""
|
||||
A test that verifies the behavior of the `convert_schedule_to_utc_from_local` function. Verifies that we use
|
||||
the preferred timezone over the browser timezone when provided both values.
|
||||
"""
|
||||
schedule = "2099-05-02T14:00:00.000Z"
|
||||
browser_timezone = "America/New_York"
|
||||
preferred_timezone = "America/Anchorage"
|
||||
set_user_preference(self.instructor, 'time_zone', preferred_timezone)
|
||||
expected_schedule = self._get_expected_schedule(schedule, preferred_timezone)
|
||||
|
||||
schedule_utc = convert_schedule_to_utc_from_local(schedule, browser_timezone, self.instructor)
|
||||
|
||||
assert schedule_utc == expected_schedule
|
||||
|
||||
def test_convert_schedule_to_utc_from_local_with_invalid_timezone(self):
|
||||
"""
|
||||
A test that verifies the behavior of the `convert_schedule_to_utc_from_local` function. Verifies an error
|
||||
condition if the application cannot determine a timezone to use for conversion.
|
||||
"""
|
||||
schedule = "2099-05-02T14:00:00.000Z"
|
||||
expected_error_message = "Unable to determine the time zone to use to convert the schedule to UTC"
|
||||
|
||||
with self.assertRaises(ValueError) as value_err:
|
||||
convert_schedule_to_utc_from_local(schedule, "Flim/Flam", self.instructor)
|
||||
|
||||
assert str(value_err.exception) == expected_error_message
|
||||
|
||||
Reference in New Issue
Block a user