api.py 21.4 KB
Newer Older
1 2 3
"""
API for submitting background tasks by an instructor for a course.

Brian Wilson committed
4 5 6
Also includes methods for getting information about tasks that have
already been submitted, filtered either by running state or input
arguments.
7 8

"""
9
import hashlib
10
from collections import Counter
11 12 13

from celery.states import READY_STATES

14 15 16 17
from bulk_email.models import CourseEmail
from certificates.models import CertificateGenerationHistory
from lms.djangoapps.instructor_task.api_helper import (
    check_arguments_for_rescoring,
18
    check_arguments_for_overriding,
19 20 21 22 23
    check_entrance_exam_problems_for_rescoring,
    encode_entrance_exam_and_student_input,
    encode_problem_and_student_input,
    submit_task
)
24 25
from lms.djangoapps.instructor_task.models import InstructorTask
from lms.djangoapps.instructor_task.tasks import (
26
    override_problem_score,
27
    calculate_grades_csv,
28
    calculate_may_enroll_csv,
29
    calculate_problem_grade_report,
30
    calculate_problem_responses_csv,
31 32
    calculate_students_features_csv,
    cohort_students,
33 34
    course_survey_report_csv,
    delete_problem_state,
35
    enrollment_report_features_csv,
36
    exec_summary_report_csv,
37
    export_ora2_data,
38
    generate_certificates,
39
    proctored_exam_results_csv,
40 41 42
    rescore_problem,
    reset_problem_attempts,
    send_bulk_course_email
43
)
44
from util import milestones_helpers
45
from xmodule.modulestore.django import modulestore
46 47


48 49 50 51 52 53 54
class SpecificStudentIdMissingError(Exception):
    """
    Exception indicating that a student id was not provided when generating a certificate for a specific student.
    """
    pass


55 56 57 58 59 60 61 62 63 64
def get_running_instructor_tasks(course_id):
    """
    Returns a query of InstructorTask objects of running tasks for a given course.

    Used to generate a list of tasks to display on the instructor dashboard.
    """
    instructor_tasks = InstructorTask.objects.filter(course_id=course_id)
    # exclude states that are "ready" (i.e. not "running", e.g. failure, success, revoked):
    for state in READY_STATES:
        instructor_tasks = instructor_tasks.exclude(task_state=state)
65
    return instructor_tasks.order_by('-id')
66 67


68
def get_instructor_task_history(course_id, usage_key=None, student=None, task_type=None):
69 70
    """
    Returns a query of InstructorTask objects of historical tasks for a given course,
71
    that optionally match a particular problem, a student, and/or a task type.
72
    """
73
    instructor_tasks = InstructorTask.objects.filter(course_id=course_id)
74 75
    if usage_key is not None or student is not None:
        _, task_key = encode_problem_and_student_input(usage_key, student)
76 77 78
        instructor_tasks = instructor_tasks.filter(task_key=task_key)
    if task_type is not None:
        instructor_tasks = instructor_tasks.filter(task_type=task_type)
79 80 81 82

    return instructor_tasks.order_by('-id')


83 84 85 86 87 88 89 90 91 92 93 94 95
def get_entrance_exam_instructor_task_history(course_id, usage_key=None, student=None):  # pylint: disable=invalid-name
    """
    Returns a query of InstructorTask objects of historical tasks for a given course,
    that optionally match an entrance exam and student if present.
    """
    instructor_tasks = InstructorTask.objects.filter(course_id=course_id)
    if usage_key is not None or student is not None:
        _, task_key = encode_entrance_exam_and_student_input(usage_key, student)
        instructor_tasks = instructor_tasks.filter(task_key=task_key)

    return instructor_tasks.order_by('-id')


96
# Disabling invalid-name because this fn name is longer than 30 chars.
97
def submit_rescore_problem_for_student(request, usage_key, student, only_if_higher=False):  # pylint: disable=invalid-name
98 99 100 101 102 103 104 105 106 107 108 109
    """
    Request a problem to be rescored as a background task.

    The problem will be rescored for the specified student only.  Parameters are the `course_id`,
    the `problem_url`, and the `student` as a User object.
    The url must specify the location of the problem, using i4x-type notation.

    ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
    if the problem is already being rescored for this student, or NotImplementedError if
    the problem doesn't support rescoring.
    """
    # check arguments:  let exceptions return up to the caller.
110
    check_arguments_for_rescoring(usage_key)
111

112
    task_type = 'rescore_problem_if_higher' if only_if_higher else 'rescore_problem'
113
    task_class = rescore_problem
114
    task_input, task_key = encode_problem_and_student_input(usage_key, student)
115
    task_input.update({'only_if_higher': only_if_higher})
116
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138


def submit_override_score(request, usage_key, student, score):
    """
    Request a problem score override as a background task. Only
    applicable to individual users.

    The problem score will be overridden for the specified student only.
    Parameters are the `course_id`, the `problem_url`, the `student` as
    a User object, and the score override desired.
    The url must specify the location of the problem, using i4x-type notation.

    ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
    if this task is already running for this student, or NotImplementedError if
    the problem is not a ScorableXBlock.
    """
    check_arguments_for_overriding(usage_key, score)
    task_type = override_problem_score.__name__
    task_class = override_problem_score
    task_input, task_key = encode_problem_and_student_input(usage_key, student)
    task_input['score'] = score
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
139 140


141
def submit_rescore_problem_for_all_students(request, usage_key, only_if_higher=False):  # pylint: disable=invalid-name
142 143 144 145 146 147 148 149 150 151 152 153 154
    """
    Request a problem to be rescored as a background task.

    The problem will be rescored for all students who have accessed the
    particular problem in a course and have provided and checked an answer.
    Parameters are the `course_id` and the `problem_url`.
    The url must specify the location of the problem, using i4x-type notation.

    ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
    if the problem is already being rescored, or NotImplementedError if the problem doesn't
    support rescoring.
    """
    # check arguments:  let exceptions return up to the caller.
155
    check_arguments_for_rescoring(usage_key)
156 157

    # check to see if task is already running, and reserve it otherwise
158
    task_type = 'rescore_problem_if_higher' if only_if_higher else 'rescore_problem'
159
    task_class = rescore_problem
160
    task_input, task_key = encode_problem_and_student_input(usage_key)
161
    task_input.update({'only_if_higher': only_if_higher})
162
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
163 164


165
def submit_rescore_entrance_exam_for_student(request, usage_key, student=None, only_if_higher=False):  # pylint: disable=invalid-name
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183
    """
    Request entrance exam problems to be re-scored as a background task.

    The entrance exam problems will be re-scored for given student or if student
    is None problems for all students who have accessed the entrance exam.

    Parameters are `usage_key`, which must be a :class:`Location`
    representing entrance exam section and the `student` as a User object.

    ItemNotFoundError is raised if entrance exam does not exists for given
    usage_key, AlreadyRunningError is raised if the entrance exam
    is already being re-scored, or NotImplementedError if the problem doesn't
    support rescoring.
    """
    # check problems for rescoring:  let exceptions return up to the caller.
    check_entrance_exam_problems_for_rescoring(usage_key)

    # check to see if task is already running, and reserve it otherwise
184
    task_type = 'rescore_problem_if_higher' if only_if_higher else 'rescore_problem'
185 186
    task_class = rescore_problem
    task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
187
    task_input.update({'only_if_higher': only_if_higher})
188 189 190
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)


191
def submit_reset_problem_attempts_for_all_students(request, usage_key):  # pylint: disable=invalid-name
192 193 194 195 196
    """
    Request to have attempts reset for a problem as a background task.

    The problem's attempts will be reset for all students who have accessed the
    particular problem in a course.  Parameters are the `course_id` and
197
    the `usage_key`, which must be a :class:`Location`.
198 199 200 201

    ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
    if the problem is already being reset.
    """
202
    # check arguments:  make sure that the usage_key is defined
203 204
    # (since that's currently typed in).  If the corresponding module descriptor doesn't exist,
    # an exception will be raised.  Let it pass up to the caller.
205
    modulestore().get_item(usage_key)
206 207 208

    task_type = 'reset_problem_attempts'
    task_class = reset_problem_attempts
209 210
    task_input, task_key = encode_problem_and_student_input(usage_key)
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
211 212


213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
def submit_reset_problem_attempts_in_entrance_exam(request, usage_key, student):  # pylint: disable=invalid-name
    """
    Request to have attempts reset for a entrance exam as a background task.

    Problem attempts for all problems in entrance exam will be reset
    for specified student. If student is None problem attempts will be
    reset for all students.

    Parameters are `usage_key`, which must be a :class:`Location`
    representing entrance exam section and the `student` as a User object.

    ItemNotFoundError is raised if entrance exam does not exists for given
    usage_key, AlreadyRunningError is raised if the entrance exam
    is already being reset.
    """
    # check arguments:  make sure entrance exam(section) exists for given usage_key
    modulestore().get_item(usage_key)

    task_type = 'reset_problem_attempts'
    task_class = reset_problem_attempts
    task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)


237
def submit_delete_problem_state_for_all_students(request, usage_key):  # pylint: disable=invalid-name
238 239 240 241 242
    """
    Request to have state deleted for a problem as a background task.

    The problem's state will be deleted for all students who have accessed the
    particular problem in a course.  Parameters are the `course_id` and
243
    the `usage_key`, which must be a :class:`Location`.
244 245

    ItemNotFoundException is raised if the problem doesn't exist, or AlreadyRunningError
246
    if the particular problem's state is already being deleted.
247
    """
248
    # check arguments:  make sure that the usage_key is defined
249 250
    # (since that's currently typed in).  If the corresponding module descriptor doesn't exist,
    # an exception will be raised.  Let it pass up to the caller.
251
    modulestore().get_item(usage_key)
252 253 254

    task_type = 'delete_problem_state'
    task_class = delete_problem_state
255 256
    task_input, task_key = encode_problem_and_student_input(usage_key)
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)
257 258


259 260 261 262 263 264 265
def submit_delete_entrance_exam_state_for_student(request, usage_key, student):  # pylint: disable=invalid-name
    """
    Requests reset of state for entrance exam as a background task.

    Module state for all problems in entrance exam will be deleted
    for specified student.

266 267
    All User Milestones of entrance exam will be removed for the specified student

268 269 270 271 272 273 274 275 276 277
    Parameters are `usage_key`, which must be a :class:`Location`
    representing entrance exam section and the `student` as a User object.

    ItemNotFoundError is raised if entrance exam does not exists for given
    usage_key, AlreadyRunningError is raised if the entrance exam
    is already being reset.
    """
    # check arguments:  make sure entrance exam(section) exists for given usage_key
    modulestore().get_item(usage_key)

278 279 280 281 282 283 284 285
    # Remove Content milestones that user has completed
    milestones_helpers.remove_course_content_user_milestones(
        course_key=usage_key.course_key,
        content_key=usage_key,
        user=student,
        relationship='fulfills'
    )

286 287 288 289 290 291
    task_type = 'delete_problem_state'
    task_class = delete_problem_state
    task_input, task_key = encode_entrance_exam_and_student_input(usage_key, student)
    return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key)


292
def submit_bulk_course_email(request, course_key, email_id):
293 294 295 296
    """
    Request to have bulk email sent as a background task.

    The specified CourseEmail object will be sent be updated for all students who have enrolled
297
    in a course.  Parameters are the `course_key` and the `email_id`, the id of the CourseEmail object.
298

299 300
    AlreadyRunningError is raised if the same recipients are already being emailed with the same
    CourseEmail object.
301
    """
302 303
    # Assume that the course is defined, and that the user has already been verified to have
    # appropriate access to the course. But make sure that the email exists.
304
    # We also pull out the targets argument here, so that is displayed in
305 306
    # the InstructorTask status.
    email_obj = CourseEmail.objects.get(id=email_id)
307 308 309 310 311 312 313
    # 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 = [
        target if count <= 1 else
        "{} {}".format(count, target)
        for target, count in targets.iteritems()
    ]
314 315 316

    task_type = 'bulk_course_email'
    task_class = send_bulk_course_email
317
    task_input = {'email_id': email_id, 'to_option': targets}
318
    task_key_stub = str(email_id)
319 320
    # create the key value by using MD5 hash:
    task_key = hashlib.md5(task_key_stub).hexdigest()
321
    return submit_task(request, task_type, task_class, course_key, task_input, task_key)
322 323 324 325 326 327 328 329 330 331 332 333 334 335 336


def submit_calculate_problem_responses_csv(request, course_key, problem_location):  # pylint: disable=invalid-name
    """
    Submits a task to generate a CSV file containing all student
    answers to a given problem.

    Raises AlreadyRunningError if said file is already being updated.
    """
    task_type = 'problem_responses_csv'
    task_class = calculate_problem_responses_csv
    task_input = {'problem_location': problem_location}
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)
337

338

339
def submit_calculate_grades_csv(request, course_key):
340 341 342 343 344 345 346 347
    """
    AlreadyRunningError is raised if the course's grades are already being updated.
    """
    task_type = 'grade_course'
    task_class = calculate_grades_csv
    task_input = {}
    task_key = ""

348
    return submit_task(request, task_type, task_class, course_key, task_input, task_key)
349 350


351 352
def submit_problem_grade_report(request, course_key):
    """
Daniel Friedman committed
353
    Submits a task to generate a CSV grade report containing problem
354 355 356 357 358 359 360 361 362
    values.
    """
    task_type = 'grade_problems'
    task_class = calculate_problem_grade_report
    task_input = {}
    task_key = ""
    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


363 364 365 366 367 368 369 370
def submit_calculate_students_features_csv(request, course_key, features):
    """
    Submits a task to generate a CSV containing student profile info.

    Raises AlreadyRunningError if said CSV is already being updated.
    """
    task_type = 'profile_info_csv'
    task_class = calculate_students_features_csv
371
    task_input = features
372 373 374
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)
375 376


377 378 379 380 381 382 383 384 385 386 387 388 389 390
def submit_detailed_enrollment_features_csv(request, course_key):  # pylint: disable=invalid-name
    """
    Submits a task to generate a CSV containing detailed enrollment info.

    Raises AlreadyRunningError if said CSV is already being updated.
    """
    task_type = 'detailed_enrollment_report'
    task_class = enrollment_report_features_csv
    task_input = {}
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


391 392 393 394 395 396 397 398 399 400 401 402 403 404 405
def submit_calculate_may_enroll_csv(request, course_key, features):
    """
    Submits a task to generate a CSV file containing information about
    invited students who have not enrolled in a given course yet.

    Raises AlreadyRunningError if said file is already being updated.
    """
    task_type = 'may_enroll_info_csv'
    task_class = calculate_may_enroll_csv
    task_input = {'features': features}
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


406
def submit_executive_summary_report(request, course_key):
Afzal Wali committed
407 408 409 410 411 412 413 414
    """
    Submits a task to generate a HTML File containing the executive summary report.

    Raises AlreadyRunningError if HTML File is already being updated.
    """
    task_type = 'exec_summary_report'
    task_class = exec_summary_report_csv
    task_input = {}
415 416 417 418 419
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


420
def submit_course_survey_report(request, course_key):
421 422 423 424 425 426 427 428 429 430 431 432 433
    """
    Submits a task to generate a HTML File containing the executive summary report.

    Raises AlreadyRunningError if HTML File is already being updated.
    """
    task_type = 'course_survey_report'
    task_class = course_survey_report_csv
    task_input = {}
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


434
def submit_proctored_exam_results_report(request, course_key):  # pylint: disable=invalid-name
435 436 437 438 439 440 441
    """
    Submits a task to generate a HTML File containing the executive summary report.

    Raises AlreadyRunningError if HTML File is already being updated.
    """
    task_type = 'proctored_exam_results_report'
    task_class = proctored_exam_results_csv
442
    task_input = {}
Afzal Wali committed
443 444 445 446 447
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


448 449 450 451 452 453 454 455 456 457 458 459
def submit_cohort_students(request, course_key, file_name):
    """
    Request to have students cohorted in bulk.

    Raises AlreadyRunningError if students are currently being cohorted.
    """
    task_type = 'cohort_students'
    task_class = cohort_students
    task_input = {'file_name': file_name}
    task_key = ""

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)
460 461


462 463 464 465 466 467 468 469 470 471 472 473
def submit_export_ora2_data(request, course_key):
    """
    AlreadyRunningError is raised if an ora2 report is already being generated.
    """
    task_type = 'export_ora2_data'
    task_class = export_ora2_data
    task_input = {}
    task_key = ''

    return submit_task(request, task_type, task_class, course_key, task_input, task_key)


474
def generate_certificates_for_students(request, course_key, student_set=None, specific_student_id=None):  # pylint: disable=invalid-name
475
    """
476 477 478 479 480 481 482 483 484 485
    Submits a task to generate certificates for given students enrolled in the course.

     Arguments:
        course_key  : Course Key
        student_set : Semantic for student collection for certificate generation.
                      Options are:
                      'all_whitelisted': All Whitelisted students.
                      'whitelisted_not_generated': Whitelisted students which does not got certificates yet.
                      'specific_student': Single student for certificate generation.
        specific_student_id : Student ID when student_set is 'specific_student'
486 487

    Raises AlreadyRunningError if certificates are currently being generated.
488 489 490 491 492 493 494 495 496 497 498 499 500 501
    Raises SpecificStudentIdMissingError if student_set is 'specific_student' and specific_student_id is 'None'
    """
    if student_set:
        task_type = 'generate_certificates_student_set'
        task_input = {'student_set': student_set}

        if student_set == 'specific_student':
            task_type = 'generate_certificates_certain_student'
            if specific_student_id is None:
                raise SpecificStudentIdMissingError(
                    "Attempted to generate certificate for a single student, "
                    "but no specific student id provided"
                )
            task_input.update({'specific_student_id': specific_student_id})
502 503 504 505 506 507
    else:
        task_type = 'generate_certificates_all_student'
        task_input = {}

    task_class = generate_certificates
    task_key = ""
508
    instructor_task = submit_task(request, task_type, task_class, course_key, task_input, task_key)
509

510 511 512 513 514 515 516 517
    CertificateGenerationHistory.objects.create(
        course_id=course_key,
        generated_by=request.user,
        instructor_task=instructor_task,
        is_regeneration=False
    )

    return instructor_task
518 519


520
def regenerate_certificates(request, course_key, statuses_to_regenerate):
521
    """
522
    Submits a task to regenerate certificates for given students enrolled in the course.
523 524 525 526 527
    Regenerate Certificate only if the status of the existing generated certificate is in 'statuses_to_regenerate'
    list passed in the arguments.

    Raises AlreadyRunningError if certificates are currently being generated.
    """
528 529
    task_type = 'regenerate_certificates_all_student'
    task_input = {}
530 531 532 533 534

    task_input.update({"statuses_to_regenerate": statuses_to_regenerate})
    task_class = generate_certificates
    task_key = ""

535 536 537 538 539 540 541 542 543 544
    instructor_task = submit_task(request, task_type, task_class, course_key, task_input, task_key)

    CertificateGenerationHistory.objects.create(
        course_id=course_key,
        generated_by=request.user,
        instructor_task=instructor_task,
        is_regeneration=True
    )

    return instructor_task