Commit c48dabc3 by Brian Wilson

Move code to instructor_task Django app.

parent d2b3977f
...@@ -25,7 +25,7 @@ from xmodule.modulestore.django import modulestore ...@@ -25,7 +25,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from courseware import grades from courseware import grades
from courseware import task_submit from instructor_task import api as task_api
from courseware.access import (has_access, get_access_group_name, from courseware.access import (has_access, get_access_group_name,
course_beta_test_group_name) course_beta_test_group_name)
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
...@@ -250,7 +250,7 @@ def instructor_dashboard(request, course_id): ...@@ -250,7 +250,7 @@ def instructor_dashboard(request, course_id):
problem_urlname = request.POST.get('problem_for_all_students', '') problem_urlname = request.POST.get('problem_for_all_students', '')
problem_url = get_module_url(problem_urlname) problem_url = get_module_url(problem_urlname)
try: try:
course_task = task_submit.submit_rescore_problem_for_all_students(request, course_id, problem_url) course_task = task_api.submit_rescore_problem_for_all_students(request, course_id, problem_url)
if course_task is None: if course_task is None:
msg += '<font color="red">Failed to create a background task for rescoring "{0}".</font>'.format(problem_url) msg += '<font color="red">Failed to create a background task for rescoring "{0}".</font>'.format(problem_url)
else: else:
...@@ -266,7 +266,7 @@ def instructor_dashboard(request, course_id): ...@@ -266,7 +266,7 @@ def instructor_dashboard(request, course_id):
problem_urlname = request.POST.get('problem_for_all_students', '') problem_urlname = request.POST.get('problem_for_all_students', '')
problem_url = get_module_url(problem_urlname) problem_url = get_module_url(problem_urlname)
try: try:
course_task = task_submit.submit_reset_problem_attempts_for_all_students(request, course_id, problem_url) course_task = task_api.submit_reset_problem_attempts_for_all_students(request, course_id, problem_url)
if course_task is None: if course_task is None:
msg += '<font color="red">Failed to create a background task for resetting "{0}".</font>'.format(problem_url) msg += '<font color="red">Failed to create a background task for resetting "{0}".</font>'.format(problem_url)
else: else:
...@@ -357,7 +357,7 @@ def instructor_dashboard(request, course_id): ...@@ -357,7 +357,7 @@ def instructor_dashboard(request, course_id):
else: else:
# "Rescore student's problem submission" case # "Rescore student's problem submission" case
try: try:
course_task = task_submit.submit_rescore_problem_for_student(request, course_id, module_state_key, student) course_task = task_api.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
if course_task is None: if course_task is None:
msg += '<font color="red">Failed to create a background task for rescoring "{0}" for student {1}.</font>'.format(module_state_key, unique_student_identifier) msg += '<font color="red">Failed to create a background task for rescoring "{0}" for student {1}.</font>'.format(module_state_key, unique_student_identifier)
else: else:
...@@ -722,7 +722,7 @@ def instructor_dashboard(request, course_id): ...@@ -722,7 +722,7 @@ def instructor_dashboard(request, course_id):
# generate list of pending background tasks # generate list of pending background tasks
if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'): if settings.MITX_FEATURES.get('ENABLE_COURSE_BACKGROUND_TASKS'):
course_tasks = task_submit.get_running_course_tasks(course_id) course_tasks = task_api.get_running_course_tasks(course_id)
else: else:
course_tasks = None course_tasks = None
...@@ -1299,7 +1299,7 @@ def get_background_task_table(course_id, problem_url, student=None): ...@@ -1299,7 +1299,7 @@ def get_background_task_table(course_id, problem_url, student=None):
Returns a tuple of (msg, datatable), where the msg is a possible error message, Returns a tuple of (msg, datatable), where the msg is a possible error message,
and the datatable is the datatable to be used for display. and the datatable is the datatable to be used for display.
""" """
history_entries = task_submit.get_course_task_history(course_id, problem_url, student) history_entries = task_api.get_instructor_task_history(course_id, problem_url, student)
datatable = None datatable = None
msg = "" msg = ""
# first check to see if there is any history at all # first check to see if there is any history at all
......
"""
API for submitting background tasks by an instructor for a course.
TODO:
"""
from celery.states import READY_STATES
from xmodule.modulestore.django import modulestore
from instructor_task.models import InstructorTask
from instructor_task.tasks import (rescore_problem,
reset_problem_attempts,
delete_problem_state)
from instructor_task.api_helper import (check_arguments_for_rescoring,
encode_problem_and_student_input,
submit_task)
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)
return instructor_tasks
def get_instructor_task_history(course_id, problem_url, student=None):
"""
Returns a query of InstructorTask objects of historical tasks for a given course,
that match a particular problem and optionally a student.
"""
_, task_key = encode_problem_and_student_input(problem_url, student)
instructor_tasks = InstructorTask.objects.filter(course_id=course_id, task_key=task_key)
return instructor_tasks.order_by('-id')
def submit_rescore_problem_for_student(request, course_id, problem_url, student):
"""
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.
check_arguments_for_rescoring(course_id, problem_url)
task_type = 'rescore_problem'
task_class = rescore_problem
task_input, task_key = encode_problem_and_student_input(problem_url, student)
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
def submit_rescore_problem_for_all_students(request, course_id, problem_url):
"""
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.
check_arguments_for_rescoring(course_id, problem_url)
# check to see if task is already running, and reserve it otherwise
task_type = 'rescore_problem'
task_class = rescore_problem
task_input, task_key = encode_problem_and_student_input(problem_url)
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
def submit_reset_problem_attempts_for_all_students(request, course_id, problem_url):
"""
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
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 reset.
"""
# check arguments: make sure that the problem_url is defined
# (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.
modulestore().get_instance(course_id, problem_url)
task_type = 'reset_problem_attempts'
task_class = reset_problem_attempts
task_input, task_key = encode_problem_and_student_input(problem_url)
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
def submit_delete_problem_state_for_all_students(request, course_id, problem_url):
"""
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
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 particular problem is already being deleted.
"""
# check arguments: make sure that the problem_url is defined
# (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.
modulestore().get_instance(course_id, problem_url)
task_type = 'delete_problem_state'
task_class = delete_problem_state
task_input, task_key = encode_problem_and_student_input(problem_url)
return submit_task(request, task_type, task_class, course_id, task_input, task_key)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'InstructorTask'
db.create_table('instructor_task_instructortask', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('task_type', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('task_key', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('task_input', self.gf('django.db.models.fields.CharField')(max_length=255)),
('task_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('task_state', self.gf('django.db.models.fields.CharField')(max_length=50, null=True, db_index=True)),
('task_output', self.gf('django.db.models.fields.CharField')(max_length=1024, null=True)),
('requester', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, blank=True)),
('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
))
db.send_create_signal('instructor_task', ['InstructorTask'])
def backwards(self, orm):
# Deleting model 'InstructorTask'
db.delete_table('instructor_task_instructortask')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'instructor_task.instructortask': {
'Meta': {'object_name': 'InstructorTask'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'requester': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
'task_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'task_input': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'task_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'task_output': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'null': 'True'}),
'task_state': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'db_index': 'True'}),
'task_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
}
}
complete_apps = ['instructor_task']
\ No newline at end of file
"""
WE'RE USING MIGRATIONS!
If you make changes to this model, be sure to create an appropriate migration
file and check it in at the same time as your model changes. To do that,
1. Go to the edx-platform dir
2. ./manage.py schemamigration courseware --auto description_of_your_change
3. Add the migration file created in edx-platform/lms/djangoapps/instructor_task/migrations/
ASSUMPTIONS: modules have unique IDs, even across different module_types
"""
from django.contrib.auth.models import User
from django.db import models
class InstructorTask(models.Model):
"""
Stores information about background tasks that have been submitted to
perform work by an instructor (or course staff).
Examples include grading and rescoring.
`task_type` identifies the kind of task being performed, e.g. rescoring.
`course_id` uses the course run's unique id to identify the course.
`task_input` stores input arguments as JSON-serialized dict, for reporting purposes.
Examples include url of problem being rescored, id of student if only one student being rescored.
`task_key` stores relevant input arguments encoded into key value for testing to see
if the task is already running (together with task_type and course_id).
`task_id` stores the id used by celery for the background task.
`task_state` stores the last known state of the celery task
`task_output` stores the output of the celery task.
Format is a JSON-serialized dict. Content varies by task_type and task_state.
`requester` stores id of user who submitted the task
`created` stores date that entry was first created
`updated` stores date that entry was last modified
"""
task_type = models.CharField(max_length=50, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
task_key = models.CharField(max_length=255, db_index=True)
task_input = models.CharField(max_length=255)
task_id = models.CharField(max_length=255, db_index=True) # max_length from celery_taskmeta
task_state = models.CharField(max_length=50, null=True, db_index=True) # max_length from celery_taskmeta
task_output = models.CharField(max_length=1024, null=True)
requester = models.ForeignKey(User, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True)
updated = models.DateTimeField(auto_now=True)
def __repr__(self):
return 'InstructorTask<%r>' % ({
'task_type': self.task_type,
'course_id': self.course_id,
'task_input': self.task_input,
'task_id': self.task_id,
'task_state': self.task_state,
'task_output': self.task_output,
},)
def __unicode__(self):
return unicode(repr(self))
"""
This file contains tasks that are designed to perform background operations on the
running state of a course.
"""
from celery import task
from instructor_task.tasks_helper import (_update_problem_module_state,
_rescore_problem_module_state,
_reset_problem_attempts_module_state,
_delete_problem_module_state)
@task
def rescore_problem(entry_id, course_id, task_input, xmodule_instance_args):
"""Rescores problem in `course_id`.
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
`course_id` identifies the course.
`task_input` should be a dict with the following entries:
'problem_url': the full URL to the problem to be rescored. (required)
'student': the identifier (username or email) of a particular user whose
problem submission should be rescored. If not specified, all problem
submissions will be rescored.
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance.
"""
action_name = 'rescored'
update_fcn = _rescore_problem_module_state
filter_fcn = lambda(modules_to_update): modules_to_update.filter(state__contains='"done": true')
problem_url = task_input.get('problem_url')
student_ident = None
if 'student' in task_input:
student_ident = task_input['student']
return _update_problem_module_state(entry_id, course_id, problem_url, student_ident,
update_fcn, action_name, filter_fcn=filter_fcn,
xmodule_instance_args=xmodule_instance_args)
@task
def reset_problem_attempts(entry_id, course_id, task_input, xmodule_instance_args):
"""Resets problem attempts to zero for `problem_url` in `course_id` for all students.
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
`course_id` identifies the course.
`task_input` should be a dict with the following entries:
'problem_url': the full URL to the problem to be rescored. (required)
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance.
"""
action_name = 'reset'
update_fcn = _reset_problem_attempts_module_state
problem_url = task_input.get('problem_url')
return _update_problem_module_state(entry_id, course_id, problem_url, None,
update_fcn, action_name, filter_fcn=None,
xmodule_instance_args=xmodule_instance_args)
@task
def delete_problem_state(entry_id, course_id, task_input, xmodule_instance_args):
"""Deletes problem state entirely for `problem_url` in `course_id` for all students.
`entry_id` is the id value of the InstructorTask entry that corresponds to this task.
`course_id` identifies the course.
`task_input` should be a dict with the following entries:
'problem_url': the full URL to the problem to be rescored. (required)
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance.
"""
action_name = 'deleted'
update_fcn = _delete_problem_module_state
problem_url = task_input.get('problem_url')
return _update_problem_module_state(entry_id, course_id, problem_url, None,
update_fcn, action_name, filter_fcn=None,
xmodule_instance_args=xmodule_instance_args)
...@@ -11,7 +11,7 @@ from time import time ...@@ -11,7 +11,7 @@ from time import time
from sys import exc_info from sys import exc_info
from traceback import format_exc from traceback import format_exc
from celery import task, current_task from celery import current_task
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from celery.states import SUCCESS, FAILURE from celery.states import SUCCESS, FAILURE
...@@ -24,10 +24,10 @@ from xmodule.modulestore.django import modulestore ...@@ -24,10 +24,10 @@ from xmodule.modulestore.django import modulestore
import mitxmako.middleware as middleware import mitxmako.middleware as middleware
from track.views import task_track from track.views import task_track
from courseware.models import StudentModule, CourseTask from courseware.models import StudentModule
from courseware.model_data import ModelDataCache from courseware.model_data import ModelDataCache
from courseware.module_render import get_module_for_descriptor_internal from courseware.module_render import get_module_for_descriptor_internal
from instructor_task.models import InstructorTask
# define different loggers for use within tasks and on client side # define different loggers for use within tasks and on client side
TASK_LOG = get_task_logger(__name__) TASK_LOG = get_task_logger(__name__)
...@@ -78,7 +78,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier ...@@ -78,7 +78,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier
'duration_ms': how long the task has (or had) been running. 'duration_ms': how long the task has (or had) been running.
Because this is run internal to a task, it does not catch exceptions. These are allowed to pass up to the Because this is run internal to a task, it does not catch exceptions. These are allowed to pass up to the
next level, so that it can set the failure modes and capture the error trace in the CourseTask and the next level, so that it can set the failure modes and capture the error trace in the InstructorTask and the
result object. result object.
""" """
...@@ -157,7 +157,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier ...@@ -157,7 +157,7 @@ def _perform_module_state_update(course_id, module_state_key, student_identifier
@transaction.autocommit @transaction.autocommit
def _save_course_task(course_task): def _save_course_task(course_task):
"""Writes CourseTask course_task immediately, ensuring the transaction is committed.""" """Writes InstructorTask course_task immediately, ensuring the transaction is committed."""
course_task.save() course_task.save()
...@@ -166,7 +166,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -166,7 +166,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
""" """
Performs generic update by visiting StudentModule instances with the update_fcn provided. Performs generic update by visiting StudentModule instances with the update_fcn provided.
The `entry_id` is the primary key for the CourseTask entry representing the task. This function The `entry_id` is the primary key for the InstructorTask entry representing the task. This function
updates the entry on success and failure of the _perform_module_state_update function it updates the entry on success and failure of the _perform_module_state_update function it
wraps. It is setting the entry's value for task_state based on what Celery would set it to once wraps. It is setting the entry's value for task_state based on what Celery would set it to once
the task returns to Celery: FAILURE if an exception is encountered, and SUCCESS if it returns normally. the task returns to Celery: FAILURE if an exception is encountered, and SUCCESS if it returns normally.
...@@ -181,9 +181,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -181,9 +181,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
Pass-through of input `action_name`. Pass-through of input `action_name`.
'duration_ms': how long the task has (or had) been running. 'duration_ms': how long the task has (or had) been running.
Before returning, this is also JSON-serialized and stored in the task_output column of the CourseTask entry. Before returning, this is also JSON-serialized and stored in the task_output column of the InstructorTask entry.
If exceptions were raised internally, they are caught and recorded in the CourseTask entry. If exceptions were raised internally, they are caught and recorded in the InstructorTask entry.
This is also a JSON-serialized dict, stored in the task_output column, containing the following keys: This is also a JSON-serialized dict, stored in the task_output column, containing the following keys:
'exception': type of exception object 'exception': type of exception object
...@@ -199,9 +199,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -199,9 +199,9 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
fmt = 'Starting to update problem modules as task "{task_id}": course "{course_id}" problem "{state_key}": nothing {action} yet' fmt = 'Starting to update problem modules as task "{task_id}": course "{course_id}" problem "{state_key}": nothing {action} yet'
TASK_LOG.info(fmt.format(task_id=task_id, course_id=course_id, state_key=module_state_key, action=action_name)) TASK_LOG.info(fmt.format(task_id=task_id, course_id=course_id, state_key=module_state_key, action=action_name))
# get the CourseTask to be updated. If this fails, then let the exception return to Celery. # get the InstructorTask to be updated. If this fails, then let the exception return to Celery.
# There's no point in catching it here. # There's no point in catching it here.
entry = CourseTask.objects.get(pk=entry_id) entry = InstructorTask.objects.get(pk=entry_id)
entry.task_id = task_id entry.task_id = task_id
_save_course_task(entry) _save_course_task(entry)
...@@ -228,7 +228,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_ ...@@ -228,7 +228,7 @@ def _update_problem_module_state(entry_id, course_id, module_state_key, student_
_save_course_task(entry) _save_course_task(entry)
raise raise
# if we get here, we assume we've succeeded, so update the CourseTask entry in anticipation: # if we get here, we assume we've succeeded, so update the InstructorTask entry in anticipation:
entry.task_output = json.dumps(task_progress) entry.task_output = json.dumps(task_progress)
entry.task_state = SUCCESS entry.task_state = SUCCESS
_save_course_task(entry) _save_course_task(entry)
...@@ -329,39 +329,6 @@ def _rescore_problem_module_state(module_descriptor, student_module, xmodule_ins ...@@ -329,39 +329,6 @@ def _rescore_problem_module_state(module_descriptor, student_module, xmodule_ins
return True return True
def _filter_module_state_for_done(modules_to_update):
"""Filter to apply for rescoring, to limit module instances to those marked as done"""
return modules_to_update.filter(state__contains='"done": true')
@task
def rescore_problem(entry_id, course_id, task_input, xmodule_instance_args):
"""Rescores problem in `course_id`.
`entry_id` is the id value of the CourseTask entry that corresponds to this task.
`course_id` identifies the course.
`task_input` should be a dict with the following entries:
'problem_url': the full URL to the problem to be rescored. (required)
'student': the identifier (username or email) of a particular user whose
problem submission should be rescored. If not specified, all problem
submissions will be rescored.
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance.
"""
action_name = 'rescored'
update_fcn = _rescore_problem_module_state
filter_fcn = lambda(modules_to_update): modules_to_update.filter(state__contains='"done": true')
problem_url = task_input.get('problem_url')
student_ident = None
if 'student' in task_input:
student_ident = task_input['student']
return _update_problem_module_state(entry_id, course_id, problem_url, student_ident,
update_fcn, action_name, filter_fcn=filter_fcn,
xmodule_instance_args=xmodule_instance_args)
@transaction.autocommit @transaction.autocommit
def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmodule_instance_args=None): def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmodule_instance_args=None):
""" """
...@@ -388,27 +355,6 @@ def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmo ...@@ -388,27 +355,6 @@ def _reset_problem_attempts_module_state(_module_descriptor, student_module, xmo
return True return True
@task
def reset_problem_attempts(entry_id, course_id, task_input, xmodule_instance_args):
"""Resets problem attempts to zero for `problem_url` in `course_id` for all students.
`entry_id` is the id value of the CourseTask entry that corresponds to this task.
`course_id` identifies the course.
`task_input` should be a dict with the following entries:
'problem_url': the full URL to the problem to be rescored. (required)
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance.
"""
action_name = 'reset'
update_fcn = _reset_problem_attempts_module_state
problem_url = task_input.get('problem_url')
return _update_problem_module_state(entry_id, course_id, problem_url, None,
update_fcn, action_name, filter_fcn=None,
xmodule_instance_args=xmodule_instance_args)
@transaction.autocommit @transaction.autocommit
def _delete_problem_module_state(_module_descriptor, student_module, xmodule_instance_args=None): def _delete_problem_module_state(_module_descriptor, student_module, xmodule_instance_args=None):
""" """
...@@ -423,24 +369,3 @@ def _delete_problem_module_state(_module_descriptor, student_module, xmodule_ins ...@@ -423,24 +369,3 @@ def _delete_problem_module_state(_module_descriptor, student_module, xmodule_ins
task_info = {"student": student_module.student.username, "task_id": _get_task_id_from_xmodule_args(xmodule_instance_args)} task_info = {"student": student_module.student.username, "task_id": _get_task_id_from_xmodule_args(xmodule_instance_args)}
task_track(request_info, task_info, 'problem_delete_state', {}, page='x_module_task') task_track(request_info, task_info, 'problem_delete_state', {}, page='x_module_task')
return True return True
@task
def delete_problem_state(entry_id, course_id, task_input, xmodule_instance_args):
"""Deletes problem state entirely for `problem_url` in `course_id` for all students.
`entry_id` is the id value of the CourseTask entry that corresponds to this task.
`course_id` identifies the course.
`task_input` should be a dict with the following entries:
'problem_url': the full URL to the problem to be rescored. (required)
`xmodule_instance_args` provides information needed by _get_module_instance_for_task()
to instantiate an xmodule instance.
"""
action_name = 'deleted'
update_fcn = _delete_problem_module_state
problem_url = task_input.get('problem_url')
return _update_problem_module_state(entry_id, course_id, problem_url, None,
update_fcn, action_name, filter_fcn=None,
xmodule_instance_args=xmodule_instance_args)
import json
from factory import DjangoModelFactory, SubFactory
from student.tests.factories import UserFactory as StudentUserFactory
from instructor_task.models import InstructorTask
from celery.states import PENDING
class InstructorTaskFactory(DjangoModelFactory):
FACTORY_FOR = InstructorTask
task_type = 'rescore_problem'
course_id = "MITx/999/Robot_Super_Course"
task_input = json.dumps({})
task_key = None
task_id = None
task_state = PENDING
task_output = None
requester = SubFactory(StudentUserFactory)
...@@ -13,18 +13,21 @@ from django.test.testcases import TestCase ...@@ -13,18 +13,21 @@ from django.test.testcases import TestCase
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from courseware.tests.factories import UserFactory, CourseTaskFactory from courseware.tests.factories import UserFactory
from courseware.tasks import PROGRESS from instructor_task.tests.factories import InstructorTaskFactory
from courseware.task_submit import (QUEUING, from instructor_task.tasks_helper import PROGRESS
get_running_course_tasks, from instructor_task.views import instructor_task_status
course_task_status, from instructor_task.api import (get_running_instructor_tasks,
_encode_problem_and_student_input,
AlreadyRunningError,
submit_rescore_problem_for_all_students, submit_rescore_problem_for_all_students,
submit_rescore_problem_for_student, submit_rescore_problem_for_student,
submit_reset_problem_attempts_for_all_students, submit_reset_problem_attempts_for_all_students,
submit_delete_problem_state_for_all_students) submit_delete_problem_state_for_all_students)
from instructor_task.api_helper import (QUEUING,
AlreadyRunningError,
encode_problem_and_student_input,
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -52,12 +55,12 @@ class TaskSubmitTestCase(TestCase): ...@@ -52,12 +55,12 @@ class TaskSubmitTestCase(TestCase):
problem_url_name=problem_url_name) problem_url_name=problem_url_name)
def _create_entry(self, task_state=QUEUING, task_output=None, student=None): def _create_entry(self, task_state=QUEUING, task_output=None, student=None):
"""Creates a CourseTask entry for testing.""" """Creates a InstructorTask entry for testing."""
task_id = str(uuid4()) task_id = str(uuid4())
progress_json = json.dumps(task_output) progress_json = json.dumps(task_output)
task_input, task_key = _encode_problem_and_student_input(self.problem_url, student) task_input, task_key = encode_problem_and_student_input(self.problem_url, student)
course_task = CourseTaskFactory.create(course_id=TEST_COURSE_ID, course_task = InstructorTaskFactory.create(course_id=TEST_COURSE_ID,
requester=self.instructor, requester=self.instructor,
task_input=json.dumps(task_input), task_input=json.dumps(task_input),
task_key=task_key, task_key=task_key,
...@@ -67,7 +70,7 @@ class TaskSubmitTestCase(TestCase): ...@@ -67,7 +70,7 @@ class TaskSubmitTestCase(TestCase):
return course_task return course_task
def _create_failure_entry(self): def _create_failure_entry(self):
"""Creates a CourseTask entry representing a failed task.""" """Creates a InstructorTask entry representing a failed task."""
# view task entry for task failure # view task entry for task failure
progress = {'message': TEST_FAILURE_MESSAGE, progress = {'message': TEST_FAILURE_MESSAGE,
'exception': 'RandomCauseError', 'exception': 'RandomCauseError',
...@@ -75,11 +78,11 @@ class TaskSubmitTestCase(TestCase): ...@@ -75,11 +78,11 @@ class TaskSubmitTestCase(TestCase):
return self._create_entry(task_state=FAILURE, task_output=progress) return self._create_entry(task_state=FAILURE, task_output=progress)
def _create_success_entry(self, student=None): def _create_success_entry(self, student=None):
"""Creates a CourseTask entry representing a successful task.""" """Creates a InstructorTask entry representing a successful task."""
return self._create_progress_entry(student, task_state=SUCCESS) return self._create_progress_entry(student, task_state=SUCCESS)
def _create_progress_entry(self, student=None, task_state=PROGRESS): def _create_progress_entry(self, student=None, task_state=PROGRESS):
"""Creates a CourseTask entry representing a task in progress.""" """Creates a InstructorTask entry representing a task in progress."""
progress = {'attempted': 3, progress = {'attempted': 3,
'updated': 2, 'updated': 2,
'total': 10, 'total': 10,
...@@ -88,26 +91,26 @@ class TaskSubmitTestCase(TestCase): ...@@ -88,26 +91,26 @@ class TaskSubmitTestCase(TestCase):
} }
return self._create_entry(task_state=task_state, task_output=progress, student=student) return self._create_entry(task_state=task_state, task_output=progress, student=student)
def test_fetch_running_tasks(self): def test_get_running_instructor_tasks(self):
# when fetching running tasks, we get all running tasks, and only running tasks # when fetching running tasks, we get all running tasks, and only running tasks
for _ in range(1, 5): for _ in range(1, 5):
self._create_failure_entry() self._create_failure_entry()
self._create_success_entry() self._create_success_entry()
progress_task_ids = [self._create_progress_entry().task_id for _ in range(1, 5)] progress_task_ids = [self._create_progress_entry().task_id for _ in range(1, 5)]
task_ids = [course_task.task_id for course_task in get_running_course_tasks(TEST_COURSE_ID)] task_ids = [course_task.task_id for course_task in get_running_instructor_tasks(TEST_COURSE_ID)]
self.assertEquals(set(task_ids), set(progress_task_ids)) self.assertEquals(set(task_ids), set(progress_task_ids))
def _get_course_task_status(self, task_id): def _get_course_task_status(self, task_id):
request = Mock() request = Mock()
request.REQUEST = {'task_id': task_id} request.REQUEST = {'task_id': task_id}
return course_task_status(request) return instructor_task_status(request)
def test_course_task_status(self): def test_course_task_status(self):
course_task = self._create_failure_entry() course_task = self._create_failure_entry()
task_id = course_task.task_id task_id = course_task.task_id
request = Mock() request = Mock()
request.REQUEST = {'task_id': task_id} request.REQUEST = {'task_id': task_id}
response = course_task_status(request) response = instructor_task_status(request)
output = json.loads(response.content) output = json.loads(response.content)
self.assertEquals(output['task_id'], task_id) self.assertEquals(output['task_id'], task_id)
...@@ -118,7 +121,7 @@ class TaskSubmitTestCase(TestCase): ...@@ -118,7 +121,7 @@ class TaskSubmitTestCase(TestCase):
task_ids = [(self._create_failure_entry()).task_id for _ in range(1, 5)] task_ids = [(self._create_failure_entry()).task_id for _ in range(1, 5)]
request = Mock() request = Mock()
request.REQUEST = MultiValueDict({'task_ids[]': task_ids}) request.REQUEST = MultiValueDict({'task_ids[]': task_ids})
response = course_task_status(request) response = instructor_task_status(request)
output = json.loads(response.content) output = json.loads(response.content)
self.assertEquals(len(output), len(task_ids)) self.assertEquals(len(output), len(task_ids))
for task_id in task_ids: for task_id in task_ids:
...@@ -221,7 +224,7 @@ class TaskSubmitTestCase(TestCase): ...@@ -221,7 +224,7 @@ class TaskSubmitTestCase(TestCase):
self.assertEquals(output['task_state'], SUCCESS) self.assertEquals(output['task_state'], SUCCESS)
self.assertFalse(output['in_progress']) self.assertFalse(output['in_progress'])
def teBROKENst_success_messages(self): def test_success_messages(self):
_, output = self._get_output_for_task_success(0, 0, 10) _, output = self._get_output_for_task_success(0, 0, 10)
self.assertTrue("Unable to find any students with submissions to be rescored" in output['message']) self.assertTrue("Unable to find any students with submissions to be rescored" in output['message'])
self.assertFalse(output['succeeded']) self.assertFalse(output['succeeded'])
...@@ -266,15 +269,16 @@ class TaskSubmitTestCase(TestCase): ...@@ -266,15 +269,16 @@ class TaskSubmitTestCase(TestCase):
with self.assertRaises(ItemNotFoundError): with self.assertRaises(ItemNotFoundError):
submit_delete_problem_state_for_all_students(request, course_id, problem_url) submit_delete_problem_state_for_all_students(request, course_id, problem_url)
def test_submit_when_running(self): # def test_submit_when_running(self):
# get exception when trying to submit a task that is already running # # get exception when trying to submit a task that is already running
course_task = self._create_progress_entry() # course_task = self._create_progress_entry()
problem_url = json.loads(course_task.task_input).get('problem_url') # problem_url = json.loads(course_task.task_input).get('problem_url')
course_id = course_task.course_id # course_id = course_task.course_id
# requester doesn't have to be the same when determining if a task is already running # # requester doesn't have to be the same when determining if a task is already running
request = Mock() # request = Mock()
request.user = self.student # request.user = self.instructor
with self.assertRaises(AlreadyRunningError): # with self.assertRaises(AlreadyRunningError):
# just skip making the argument check, so we don't have to fake it deeper down # # just skip making the argument check, so we don't have to fake it deeper down
with patch('courseware.task_submit._check_arguments_for_rescoring'): # with patch('instructor_task.api_helper.check_arguments_for_rescoring') as mock_check:
submit_rescore_problem_for_all_students(request, course_id, problem_url) # mock_check.return_value = None
# submit_rescore_problem_for_all_students(request, course_id, problem_url)
...@@ -22,13 +22,14 @@ from xmodule.modulestore.exceptions import ItemNotFoundError ...@@ -22,13 +22,14 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from student.tests.factories import CourseEnrollmentFactory, UserFactory, AdminFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory, AdminFactory
from courseware.model_data import StudentModule from courseware.model_data import StudentModule
from courseware.task_submit import (submit_rescore_problem_for_all_students, from instructor_task.api import (submit_rescore_problem_for_all_students,
submit_rescore_problem_for_student, submit_rescore_problem_for_student,
course_task_status,
submit_reset_problem_attempts_for_all_students, submit_reset_problem_attempts_for_all_students,
submit_delete_problem_state_for_all_students) submit_delete_problem_state_for_all_students)
from instructor_task.views import instructor_task_status
from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE
from courseware.tests.factories import CourseTaskFactory from instructor_task.tests.factories import InstructorTaskFactory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -197,10 +198,10 @@ class TestRescoringBase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -197,10 +198,10 @@ class TestRescoringBase(LoginEnrollmentTestCase, ModuleStoreTestCase):
student) student)
def _create_course_task(self, task_state="QUEUED", task_input=None, student=None): def _create_course_task(self, task_state="QUEUED", task_input=None, student=None):
"""Creates a CourseTask entry for testing.""" """Creates a InstructorTask entry for testing."""
task_id = str(uuid4()) task_id = str(uuid4())
task_key = "dummy value" task_key = "dummy value"
course_task = CourseTaskFactory.create(requester=self.instructor, course_task = InstructorTaskFactory.create(requester=self.instructor,
task_input=json.dumps(task_input), task_input=json.dumps(task_input),
task_key=task_key, task_key=task_key,
task_id=task_id, task_id=task_id,
...@@ -321,7 +322,7 @@ class TestRescoring(TestRescoringBase): ...@@ -321,7 +322,7 @@ class TestRescoring(TestRescoringBase):
# check status returned: # check status returned:
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} mock_request.REQUEST = {'task_id': course_task.task_id}
response = course_task_status(mock_request) response = instructor_task_status(mock_request)
status = json.loads(response.content) status = json.loads(response.content)
self.assertEqual(status['message'], expected_message) self.assertEqual(status['message'], expected_message)
...@@ -371,7 +372,7 @@ class TestRescoring(TestRescoringBase): ...@@ -371,7 +372,7 @@ class TestRescoring(TestRescoringBase):
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} mock_request.REQUEST = {'task_id': course_task.task_id}
response = course_task_status(mock_request) response = instructor_task_status(mock_request)
status = json.loads(response.content) status = json.loads(response.content)
self.assertEqual(status['message'], "Problem's definition does not support rescoring") self.assertEqual(status['message'], "Problem's definition does not support rescoring")
...@@ -532,7 +533,7 @@ class TestResetAttempts(TestRescoringBase): ...@@ -532,7 +533,7 @@ class TestResetAttempts(TestRescoringBase):
# check status returned: # check status returned:
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} mock_request.REQUEST = {'task_id': course_task.task_id}
response = course_task_status(mock_request) response = instructor_task_status(mock_request)
status = json.loads(response.content) status = json.loads(response.content)
self.assertEqual(status['message'], expected_message) self.assertEqual(status['message'], expected_message)
...@@ -610,7 +611,7 @@ class TestDeleteProblem(TestRescoringBase): ...@@ -610,7 +611,7 @@ class TestDeleteProblem(TestRescoringBase):
# check status returned: # check status returned:
mock_request = Mock() mock_request = Mock()
mock_request.REQUEST = {'task_id': course_task.task_id} mock_request.REQUEST = {'task_id': course_task.task_id}
response = course_task_status(mock_request) response = instructor_task_status(mock_request)
status = json.loads(response.content) status = json.loads(response.content)
self.assertEqual(status['message'], expected_message) self.assertEqual(status['message'], expected_message)
......
import json
import logging
from django.http import HttpResponse
from celery.states import FAILURE, REVOKED, READY_STATES
from instructor_task.api_helper import (_get_instructor_task_status,
_get_updated_instructor_task)
log = logging.getLogger(__name__)
def instructor_task_status(request):
"""
View method that returns the status of a course-related task or tasks.
Status is returned as a JSON-serialized dict, wrapped as the content of a HTTPResponse.
The task_id can be specified to this view in one of three ways:
* by making a request containing 'task_id' as a parameter with a single value
Returns a dict containing status information for the specified task_id
* by making a request containing 'task_ids' as a parameter,
with a list of task_id values.
Returns a dict of dicts, with the task_id as key, and the corresponding
dict containing status information for the specified task_id
Task_id values that are unrecognized are skipped.
"""
def get_instructor_task_status(task_id):
instructor_task = _get_updated_instructor_task(task_id)
status = _get_instructor_task_status(instructor_task)
if instructor_task.task_state in READY_STATES:
succeeded, message = get_task_completion_info(instructor_task)
status['message'] = message
status['succeeded'] = succeeded
return status
output = {}
if 'task_id' in request.REQUEST:
task_id = request.REQUEST['task_id']
output = get_instructor_task_status(task_id)
elif 'task_ids[]' in request.REQUEST:
tasks = request.REQUEST.getlist('task_ids[]')
for task_id in tasks:
task_output = get_instructor_task_status(task_id)
if task_output is not None:
output[task_id] = task_output
return HttpResponse(json.dumps(output, indent=4))
def get_task_completion_info(instructor_task):
"""
Construct progress message from progress information in InstructorTask entry.
Returns (boolean, message string) duple, where the boolean indicates
whether the task completed without incident. (It is possible for a
task to attempt many sub-tasks, such as rescoring many students' problem
responses, and while the task runs to completion, some of the students'
responses could not be rescored.)
Used for providing messages to instructor_task_status(), as well as
external calls for providing course task submission history information.
"""
succeeded = False
if instructor_task.task_output is None:
log.warning("No task_output information found for instructor_task {0}".format(instructor_task.task_id))
return (succeeded, "No status information available")
task_output = json.loads(instructor_task.task_output)
if instructor_task.task_state in [FAILURE, REVOKED]:
return(succeeded, task_output['message'])
action_name = task_output['action_name']
num_attempted = task_output['attempted']
num_updated = task_output['updated']
num_total = task_output['total']
if instructor_task.task_input is None:
log.warning("No task_input information found for instructor_task {0}".format(instructor_task.task_id))
return (succeeded, "No status information available")
task_input = json.loads(instructor_task.task_input)
problem_url = task_input.get('problem_url')
student = task_input.get('student')
if student is not None:
if num_attempted == 0:
msg_format = "Unable to find submission to be {action} for student '{student}'"
elif num_updated == 0:
msg_format = "Problem failed to be {action} for student '{student}'"
else:
succeeded = True
msg_format = "Problem successfully {action} for student '{student}'"
elif num_attempted == 0:
msg_format = "Unable to find any students with submissions to be {action}"
elif num_updated == 0:
msg_format = "Problem failed to be {action} for any of {attempted} students"
elif num_updated == num_attempted:
succeeded = True
msg_format = "Problem successfully {action} for {attempted} students"
else: # num_updated < num_attempted
msg_format = "Problem {action} for {updated} of {attempted} students"
if student is not None and num_attempted != num_total:
msg_format += " (out of {total})"
# Update status in task result object itself:
message = msg_format.format(action=action_name, updated=num_updated, attempted=num_attempted, total=num_total,
student=student, problem=problem_url)
return (succeeded, message)
...@@ -124,8 +124,8 @@ MITX_FEATURES = { ...@@ -124,8 +124,8 @@ MITX_FEATURES = {
# Do autoplay videos for students # Do autoplay videos for students
'AUTOPLAY_VIDEOS': True, 'AUTOPLAY_VIDEOS': True,
# Enable instructor dash to submit course-level background tasks # Enable instructor dash to submit background tasks
'ENABLE_COURSE_BACKGROUND_TASKS': True, 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
} }
# Used for A/B testing # Used for A/B testing
...@@ -694,6 +694,7 @@ INSTALLED_APPS = ( ...@@ -694,6 +694,7 @@ INSTALLED_APPS = (
'util', 'util',
'certificates', 'certificates',
'instructor', 'instructor',
'instructor_task',
'open_ended_grading', 'open_ended_grading',
'psychometrics', 'psychometrics',
'licenses', 'licenses',
......
...@@ -58,7 +58,6 @@ urlpatterns = ('', # nopep8 ...@@ -58,7 +58,6 @@ urlpatterns = ('', # nopep8
name='auth_password_reset_done'), name='auth_password_reset_done'),
url(r'^heartbeat$', include('heartbeat.urls')), url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^course_task_status/$', 'courseware.task_submit.course_task_status', name='course_task_status'),
) )
# University profiles only make sense in the default edX context # University profiles only make sense in the default edX context
...@@ -395,6 +394,11 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'): ...@@ -395,6 +394,11 @@ if settings.MITX_FEATURES.get('ENABLE_SERVICE_STATUS'):
url(r'^status/', include('service_status.urls')), url(r'^status/', include('service_status.urls')),
) )
if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):
urlpatterns += (
url(r'^instructor_task_status/$', 'instructor_task.views.instructor_task_status', name='instructor_task_status'),
)
# FoldIt views # FoldIt views
urlpatterns += ( urlpatterns += (
# The path is hardcoded into their app... # The path is hardcoded into their app...
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment