Régis Behmo 029ac27251 feat: support for user-submitted files
Previously, user-uploaded files were stored in the /openedx/xqueue/openedx
folder (considered by django to be a media subfolder). This folder was not
being mounted on the host, thus causing files to be lost on container restart.
Also, files were simply not reported by the `tutor submissions show` utility.

We now bind-mount the media folder from the host. Media assets are served by
uwsgi, which replaced gunicorn.

When object storage is in use, we will have to point xqueue to the remote
storage solution. This means that we need to add patches to the tutor-minio
plugin.

Close #2.
2021-09-02 10:48:12 +02:00

198 lines
6.1 KiB
Python

from glob import glob
import json
import os
import click
import pkg_resources
import requests
from tutor import config as tutor_config
from tutor.exceptions import TutorError
from .__about__ import __version__
config = {
"add": {
"AUTH_PASSWORD": "{{ 8|random_string }}",
"MYSQL_PASSWORD": "{{ 8|random_string }}",
"SECRET_KEY": "{{ 24|random_string }}",
},
"defaults": {
"VERSION": __version__,
"AUTH_USERNAME": "lms",
"DOCKER_IMAGE": "{{ DOCKER_REGISTRY }}overhangio/openedx-xqueue:{{ XQUEUE_VERSION }}",
"HOST": "xqueue.{{ LMS_HOST }}",
"MYSQL_DATABASE": "xqueue",
"MYSQL_USERNAME": "xqueue",
},
}
templates = pkg_resources.resource_filename("tutorxqueue", "templates")
hooks = {
"init": ["mysql", "xqueue"],
"build-image": {"xqueue": "{{ XQUEUE_DOCKER_IMAGE }}"},
"remote-image": {"xqueue": "{{ XQUEUE_DOCKER_IMAGE }}"},
}
def patches():
all_patches = {}
for path in glob(
os.path.join(pkg_resources.resource_filename("tutorxqueue", "patches"), "*")
):
with open(path) as patch_file:
name = os.path.basename(path)
content = patch_file.read()
all_patches[name] = content
return all_patches
@click.group(help="Interact with the Xqueue server")
def command():
pass
@click.group(help="List and grade submissions")
@click.pass_obj
@click.option("-q", "--queue", default="openedx", show_default=True, help="Queue name")
@click.option(
"-u",
"--url",
envvar="TUTOR_XQUEUE_URL",
help="Xqueue server base url. By default, this value will be defined from the plugin configuration. Alternatively, this value can be defined from the TUTOR_XQUEUE_URL environment variable.",
)
def submissions(context, queue, url):
context.queue = queue
context.url = url
@click.command(name="count", help="Count submissions in queue")
@click.pass_obj
def count_submissions(context):
print_result(context, "count_submissions", context.queue)
@click.command(name="show", help="Show last submission")
@click.pass_obj
def show_submission(context):
print_result(context, "show_submission", context.queue)
@click.command(name="grade", help="Grade a specific submission")
@click.argument("submission_id")
@click.argument("submission_key")
@click.argument("grade", type=click.FLOAT)
@click.argument("correct", type=click.BOOL)
@click.argument("message")
@click.pass_obj
def grade_submission(context, submission_id, submission_key, grade, correct, message):
print_result(
context,
"grade_submission",
submission_id,
submission_key,
grade,
correct,
message,
)
def print_result(context, client_func_name, *args, **kwargs):
user_config = tutor_config.load(context.root)
client = Client(user_config, url=context.url)
func = getattr(client, client_func_name)
result = func(*args, **kwargs)
print(json.dumps(result, indent=2))
class Client:
def __init__(self, user_config, url=None):
self._session = None
self.username = user_config["XQUEUE_AUTH_USERNAME"]
self.password = user_config["XQUEUE_AUTH_PASSWORD"]
self.base_url = url
if not self.base_url:
scheme = "https" if user_config["ENABLE_HTTPS"] else "http"
host = user_config["XQUEUE_HOST"]
self.base_url = "{}://{}".format(scheme, host)
self.login()
@property
def session(self):
if self._session is None:
self._session = requests.Session()
return self._session
def url(self, endpoint):
# Don't forget to add a trailing slash to all endpoints: this is how xqueue
# works...
return self.base_url + endpoint
def login(self):
response = self.request(
"/xqueue/login/",
method="POST",
data={"username": self.username, "password": self.password},
)
message = response.get("content")
if message != "Logged in":
raise TutorError(
"Could not login to xqueue server at {}. Response: '{}'".format(
self.base_url, message
)
)
def show_submission(self, queue):
response = self.request("/xqueue/get_submission/", params={"queue_name": queue})
if response["return_code"] != 0:
return response
data = json.loads(response["content"])
header = json.loads(data["xqueue_header"])
submission_body = json.loads(data["xqueue_body"])
submission_id = header["submission_id"]
submission_key = header["submission_key"]
submission_files = {}
for filename, path in json.loads(data["xqueue_files"]).items():
if not path.startswith("http"):
# Relative path: prepend with server url
path = self.base_url + "/" + path
submission_files[filename] = path
return {
"id": submission_id,
"key": submission_key,
"body": submission_body,
"files": submission_files,
"return_code": response["return_code"],
}
def count_submissions(self, queue):
return self.request("/xqueue/get_queuelen/", params={"queue_name": queue})
def grade_submission(self, submission_id, submission_key, grade, correct, msg):
return self.request(
"/xqueue/put_result/",
method="POST",
data={
"xqueue_header": json.dumps(
{"submission_id": submission_id, "submission_key": submission_key}
),
"xqueue_body": json.dumps(
{"correct": correct, "score": grade, "msg": msg}
),
},
)
def request(self, endpoint, method="GET", data=None, params=None):
func = getattr(self.session, method.lower())
response = func(self.url(endpoint), data=data, params=params)
# TODO handle errors >= 400 and non-parsable json responses
return response.json()
submissions.add_command(count_submissions)
submissions.add_command(show_submission)
submissions.add_command(grade_submission)
command.add_command(submissions)