Commit b82bc000 authored by Konstantin Schulz's avatar Konstantin Schulz
Browse files

H5P can now be downloaded and then uploaded into Moodle

parent 041513db
Pipeline #12407 passed with stages
in 2 minutes and 43 seconds
......@@ -8,11 +8,10 @@
`git clone https://scm.cms.hu-berlin.de/callidus/machina-callida.git`.
3. Move to the newly created folder:
`cd machina-callida`.
4. Run `docker-compose build`.
5. Run `docker-compose up -d`.
4. Run `./deploy.sh`.
When using the application for the first time, it may take a few minutes until the container "mc_frontend" has finished compiling the application.
6. Visit http://localhost:8100.
5. Visit http://localhost:8100.
### Command line
For installation via command line, see the respective subdirectories (`mc_frontend` and `mc_backend`).
......
x=$(git tag --points-at HEAD)
echo "export const version = '${x}';" > mc_frontend/src/version.ts
docker-compose build
docker-compose down
docker-compose up -d
......@@ -51,6 +51,8 @@ services:
- "5000:5000"
restart: always
stdin_open: true
volumes:
- ./mc_frontend/src/assets/h5p:/home/mc/h5p
nginx:
command: nginx -g "daemon off;"
image: nginx:alpine
......
......@@ -52,8 +52,7 @@ def get(id: str, type: FileType, solution_indices: List[int]) -> Union[ETagRespo
(x for x in FileService.downloadable_files if x.id + "." + str(x.file_type) == file_name), None)
if existing_file is None:
existing_file = FileService.make_tmp_file_from_exercise(type, exercise, solution_indices)
return send_from_directory(Config.TMP_DIRECTORY, existing_file.file_name, mimetype=mime_type,
as_attachment=True)
return send_from_directory(Config.TMP_DIRECTORY, existing_file.file_name, mimetype=mime_type, as_attachment=True)
def post(file_data: dict) -> Response:
......
from typing import List, Union
import json
import os
import shutil
import zipfile
from typing import List, Union, Set
import connexion
from connexion.lifecycle import ConnexionResponse
from flask import Response
from flask import Response, send_from_directory
from mcserver import Config
from mcserver.app import db
from mcserver.app.models import Language, ExerciseType, Solution
from mcserver.app.models import Language, ExerciseType, Solution, MimeType, FileType
from mcserver.app.services import TextService, NetworkService, DatabaseService
from mcserver.models_auto import Exercise
from openapi.openapi_server.models import H5PForm
def get(eid: str, lang: str, solution_indices: List[int]) -> Union[Response, ConnexionResponse]:
""" The GET method for the H5P REST API. It provides JSON templates for client-side H5P exercise layouts. """
def determine_language(lang: str) -> Language:
"""Convert the given language ISO code to our internal enum-style representation of languages."""
language: Language
try:
language = Language(lang)
except ValueError:
language = Language.English
return language
def get(eid: str, lang: str, solution_indices: List[int]) -> Union[Response, ConnexionResponse]:
""" The GET method for the H5P REST API. It provides JSON templates for client-side H5P exercise layouts. """
language: Language = determine_language(lang)
exercise: Exercise = db.session.query(Exercise).filter_by(eid=eid).first()
DatabaseService.commit()
if exercise is None:
return connexion.problem(404, Config.ERROR_TITLE_NOT_FOUND, Config.ERROR_MESSAGE_EXERCISE_NOT_FOUND)
text_field_content: str = ""
if exercise.exercise_type in [ExerciseType.cloze.value, ExerciseType.markWords.value]:
text_field_content = TextService.get_h5p_text_with_solutions(exercise, solution_indices)
elif exercise.exercise_type == ExerciseType.matching.value:
solutions: List[Solution] = TextService.get_solutions_by_index(exercise, solution_indices)
for solution in solutions:
text_field_content += "{0} *{1}*\n".format(solution.target.content, solution.value.content)
else:
text_field_content: str = get_text_field_content(exercise, solution_indices)
if not text_field_content:
return connexion.problem(
422, Config.ERROR_TITLE_UNPROCESSABLE_ENTITY, Config.ERROR_MESSAGE_UNPROCESSABLE_ENTITY)
response_dict: dict = TextService.json_template_mark_words
......@@ -38,6 +43,7 @@ def get(eid: str, lang: str, solution_indices: List[int]) -> Union[Response, Con
def get_response(response_dict: dict, lang: Language, json_template_drag_text: dict, exercise: Exercise,
text_field_content: str, feedback_template: str) -> dict:
"""Perform localization for an existing H5P exercise template and insert the relevant exercise materials."""
# default values for buttons and response
button_dict: dict = {"check": ["checkAnswerButton", "Prüfen" if lang == Language.German else "Check"],
"again": ["tryAgainButton", "Nochmal" if lang == Language.German else "Retry"],
......@@ -57,3 +63,62 @@ def get_response(response_dict: dict, lang: Language, json_template_drag_text: d
feedback = feedback_template.format("Score", "of")
response_dict["overallFeedback"][0]["feedback"] = feedback
return response_dict
def get_text_field_content(exercise: Exercise, solution_indices: List[int]) -> str:
"""Build the text field content for a H5P exercise, i.e. the task, exercise material and solutions."""
text_field_content: str = ""
if exercise.exercise_type in [ExerciseType.cloze.value, ExerciseType.markWords.value]:
text_field_content = TextService.get_h5p_text_with_solutions(exercise, solution_indices)
elif exercise.exercise_type == ExerciseType.matching.value:
solutions: List[Solution] = TextService.get_solutions_by_index(exercise, solution_indices)
for solution in solutions:
text_field_content += "{0} *{1}*\n".format(solution.target.content, solution.value.content)
return text_field_content
def make_h5p_archive(file_name_no_ext: str, response_dict: dict, target_dir: str, file_name: str):
"""Creates a H5P archive (in ZIP format) for a given exercise."""
source_dir: str = os.path.join(Config.H5P_DIRECTORY, file_name_no_ext)
content_dir: str = os.path.join(Config.TMP_DIRECTORY, "content")
os.makedirs(content_dir, exist_ok=True)
content_file_path: str = os.path.join(content_dir, "content.json")
json.dump(response_dict, open(content_file_path, "w+"))
# exclude empty directories from the archive because the Moodle H5P importer cannot handle them
white_list: Set[str] = {'.svg', '.otf', '.json', '.css', '.diff', '.woff', '.eot', '.png', '.gif',
'.woff2', '.js', '.ttf'}
with zipfile.ZipFile(os.path.join(target_dir, file_name), "w") as zipObj:
# Iterate over all the files in directory
for folder_name, subfolders, file_names in os.walk(source_dir):
if folder_name.endswith("content"):
continue
for new_file_name in file_names:
# create complete filepath of file in directory
new_file_path: str = os.path.join(folder_name, new_file_name)
if os.path.splitext(new_file_path)[1] in white_list:
# Add file to zip
zipObj.write(filename=new_file_path, arcname=new_file_path.replace(source_dir, ""))
zipObj.write(filename=content_file_path, arcname=content_file_path.replace(Config.TMP_DIRECTORY, ""))
shutil.rmtree(content_dir)
def post(h5p_data: dict):
""" The POST method for the H5P REST API. It offers client-side H5P exercises for download as ZIP archives. """
h5p_form: H5PForm = H5PForm.from_dict(h5p_data)
language: Language = determine_language(h5p_form.lang)
exercise: Exercise = db.session.query(Exercise).filter_by(eid=h5p_form.eid).first()
DatabaseService.commit()
if exercise is None:
return connexion.problem(404, Config.ERROR_TITLE_NOT_FOUND, Config.ERROR_MESSAGE_EXERCISE_NOT_FOUND)
text_field_content: str = get_text_field_content(exercise, h5p_form.solution_indices)
if not text_field_content:
return connexion.problem(
422, Config.ERROR_TITLE_UNPROCESSABLE_ENTITY, Config.ERROR_MESSAGE_UNPROCESSABLE_ENTITY)
response_dict: dict = TextService.json_template_mark_words
response_dict = get_response(response_dict, language, TextService.json_template_drag_text, exercise,
text_field_content, TextService.feedback_template)
file_name_no_ext: str = str(h5p_form.exercise_type_path)
file_name: str = f"{file_name_no_ext}.{FileType.ZIP}"
target_dir: str = Config.TMP_DIRECTORY
make_h5p_archive(file_name_no_ext, response_dict, target_dir, file_name)
return send_from_directory(target_dir, file_name, mimetype=MimeType.zip.value, as_attachment=True)
......@@ -18,6 +18,7 @@ from requests import Response
from mcserver.app.models import StaticExercise
from mcserver.app.services import NetworkService, AnnotationService
from mcserver.config import Config
from openapi.openapi_server.models import ExerciseTypePath
def get() -> Union[Response, ConnexionResponse]:
......@@ -46,18 +47,18 @@ def get_relevant_strings(response: Response):
content: dict = json.loads(zip_file.read(name).decode("utf-8"))
if url not in relevant_strings_dict:
relevant_strings_dict[url] = set()
if Config.H5P_DRAG_TEXT in name:
if ExerciseTypePath.DRAG_TEXT in name:
text_field_content: str = content["textField"]
asterisks: List[int] = [i for i, char in enumerate(text_field_content) if char == "*"]
for i in range(round(len(asterisks) / 2)):
solution_text: str = text_field_content[(asterisks[i * 2] + 1):asterisks[(i * 2) + 1]]
for target in solution_text.split(":")[0].strip().split():
relevant_strings_dict[url].add(target)
elif Config.H5P_FILL_BLANKS in name:
elif ExerciseTypePath.FILL_BLANKS in name:
handle_fill_blanks(content, file_name, fill_blanks_black_list, url, relevant_strings_dict)
elif Config.H5P_MULTI_CHOICE in name and file_name not in multi_choice_black_list:
elif ExerciseTypePath.MULTI_CHOICE in name and file_name not in multi_choice_black_list:
handle_multi_choice(content, url, relevant_strings_dict, file_name)
elif Config.H5P_VOC_LIST in name:
elif ExerciseTypePath.VOC_LIST in name:
handle_voc_list(content, url, relevant_strings_dict)
return relevant_strings_dict
......
drag_text/
fill_blanks/
mark_words/
multi_choice/
voc_list/
......@@ -105,8 +105,10 @@ class Lemma(Enum):
class MimeType(Enum):
docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
json = "application/json"
pdf = "application/pdf"
xml = "text/xml"
zip = "application/zip"
class ObjectType(Enum):
......
......@@ -29,6 +29,7 @@ class Config(object):
MC_SERVER_APP_DIRECTORY = os.path.join(MC_SERVER_DIRECTORY, "app") if os.path.split(MC_SERVER_DIRECTORY)[
-1] == "mcserver" else MC_SERVER_DIRECTORY
IS_DOCKER = os.environ.get("IS_THIS_A_DOCKER_CONTAINER", False)
MC_FRONTEND_DIRECTORY = os.path.join(Path(MC_SERVER_DIRECTORY).parent.parent, "mc_frontend")
ASSETS_DIRECTORY = os.path.join(MC_SERVER_APP_DIRECTORY, "assets")
FILES_DIRECTORY = os.path.join(MC_SERVER_APP_DIRECTORY, "files")
TMP_DIRECTORY = os.path.join(FILES_DIRECTORY, "tmp")
......@@ -95,10 +96,7 @@ class Config(object):
FLASK_MIGRATE = "migrate"
GRAPHANNIS_DEPENDENCY_LINK = "dep"
GRAPHANNIS_LOG_PATH = os.path.join(os.getcwd(), "graphannis.log")
H5P_DRAG_TEXT = "drag_text"
H5P_FILL_BLANKS = "fill_blanks"
H5P_MULTI_CHOICE = "multi_choice"
H5P_VOC_LIST = "voc_list"
H5P_DIRECTORY = "/home/mc/h5p" if IS_DOCKER else os.path.join(MC_FRONTEND_DIRECTORY, "src", "assets", "h5p")
# Windows: use 127.0.0.1 as host IP fallback
HOST_IP_FALLBACK = "0.0.0.0"
HOST_IP_CSM = DOCKER_SERVICE_NAME_CSM if IS_DOCKER else HOST_IP_FALLBACK
......
......@@ -254,6 +254,22 @@ paths:
- $ref: '../openapi_models.yaml#/components/parameters/EidParam'
- $ref: '../openapi_models.yaml#/components/parameters/LangParam'
- $ref: '../openapi_models.yaml#/components/parameters/SolutionIndicesParam'
post:
summary: Offers H5P exercises for download as ZIP archives (with the H5P file extension).
operationId: mcserver.app.api.h5pAPI.post
responses:
"200":
description: ZIP archive (with the H5P file extension) containing data for a H5P exercise.
content:
application/zip:
schema:
type: object
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '../openapi_models.yaml#/components/schemas/H5PForm'
/kwic:
post:
summary: Provides example contexts for a given phenomenon in a given corpus.
......
......@@ -3,6 +3,7 @@ import six
from openapi.openapi_server.models.annis_response import AnnisResponse # noqa: E501
from openapi.openapi_server.models.corpus import Corpus # noqa: E501
from openapi.openapi_server.models.exercise_type_path import ExerciseTypePath # noqa: E501
from openapi.openapi_server.models.file_type import FileType # noqa: E501
from openapi.openapi_server.models.frequency_item import FrequencyItem # noqa: E501
from openapi.openapi_server.models.matching_exercise import MatchingExercise # noqa: E501
......@@ -186,6 +187,27 @@ def mcserver_app_api_h5p_api_get(eid, lang, solution_indices=None): # noqa: E50
return 'do some magic!'
def mcserver_app_api_h5p_api_post(eid=None, exercise_type_path=None, lang=None, solution_indices=None): # noqa: E501
"""Offers H5P exercises for download as ZIP archives (with the H5P file extension).
# noqa: E501
:param eid: Unique identifier (UUID) for the exercise.
:type eid: str
:param exercise_type_path:
:type exercise_type_path: dict | bytes
:param lang: ISO 639-1 Language Code for the localization of exercise content.
:type lang: str
:param solution_indices: Indices for the solutions that should be included in the download.
:type solution_indices: List[int]
:rtype: object
"""
if connexion.request.is_json:
exercise_type_path = ExerciseTypePath.from_dict(connexion.request.get_json()) # noqa: E501
return 'do some magic!'
def mcserver_app_api_kwic_api_post(search_values, urn, ctx_left, ctx_right): # noqa: E501
"""Provides example contexts for a given phenomenon in a given corpus.
......
......@@ -9,9 +9,11 @@ from openapi.openapi_server.models.exercise_base import ExerciseBase
from openapi.openapi_server.models.exercise_extension import ExerciseExtension
from openapi.openapi_server.models.exercise_form import ExerciseForm
from openapi.openapi_server.models.exercise_form_all_of import ExerciseFormAllOf
from openapi.openapi_server.models.exercise_type_path import ExerciseTypePath
from openapi.openapi_server.models.file_type import FileType
from openapi.openapi_server.models.frequency_item import FrequencyItem
from openapi.openapi_server.models.graph_data import GraphData
from openapi.openapi_server.models.h5_p_form import H5PForm
from openapi.openapi_server.models.inline_object import InlineObject
from openapi.openapi_server.models.kwic_form import KwicForm
from openapi.openapi_server.models.link import Link
......
# coding: utf-8
from __future__ import absolute_import
from datetime import date, datetime # noqa: F401
from typing import List, Dict # noqa: F401
from openapi.openapi_server.models.base_model_ import Model
from openapi.openapi_server import util
class ExerciseTypePath(Model):
"""NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
Do not edit the class manually.
"""
"""
allowed enum values
"""
DRAG_TEXT = "drag_text"
FILL_BLANKS = "fill_blanks"
MARK_WORDS = "mark_words"
MULTI_CHOICE = "multi_choice"
VOC_LIST = "voc_list"
def __init__(self): # noqa: E501
"""ExerciseTypePath - a model defined in OpenAPI
"""
self.openapi_types = {
}
self.attribute_map = {
}
@classmethod
def from_dict(cls, dikt) -> 'ExerciseTypePath':
"""Returns the dict as a model
:param dikt: A dict.
:type: dict
:return: The ExerciseTypePath of this ExerciseTypePath. # noqa: E501
:rtype: ExerciseTypePath
"""
return util.deserialize_model(dikt, cls)
......@@ -22,6 +22,7 @@ class FileType(Model):
JSON = "json"
PDF = "pdf"
XML = "xml"
ZIP = "zip"
def __init__(self): # noqa: E501
"""FileType - a model defined in OpenAPI
......
# coding: utf-8
from __future__ import absolute_import
from datetime import date, datetime # noqa: F401
from typing import List, Dict # noqa: F401
from openapi.openapi_server.models.base_model_ import Model
from openapi.openapi_server.models.exercise_type_path import ExerciseTypePath
from openapi.openapi_server import util
from openapi.openapi_server.models.exercise_type_path import ExerciseTypePath # noqa: E501
class H5PForm(Model):
"""NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
Do not edit the class manually.
"""
def __init__(self, eid=None, exercise_type_path=None, lang=None, solution_indices=None): # noqa: E501
"""H5PForm - a model defined in OpenAPI
:param eid: The eid of this H5PForm. # noqa: E501
:type eid: str
:param exercise_type_path: The exercise_type_path of this H5PForm. # noqa: E501
:type exercise_type_path: ExerciseTypePath
:param lang: The lang of this H5PForm. # noqa: E501
:type lang: str
:param solution_indices: The solution_indices of this H5PForm. # noqa: E501
:type solution_indices: List[int]
"""
self.openapi_types = {
'eid': str,
'exercise_type_path': ExerciseTypePath,
'lang': str,
'solution_indices': List[int]
}
self.attribute_map = {
'eid': 'eid',
'exercise_type_path': 'exercise_type_path',
'lang': 'lang',
'solution_indices': 'solution_indices'
}
self._eid = eid
self._exercise_type_path = exercise_type_path
self._lang = lang
self._solution_indices = solution_indices
@classmethod
def from_dict(cls, dikt) -> 'H5PForm':
"""Returns the dict as a model
:param dikt: A dict.
:type: dict
:return: The H5PForm of this H5PForm. # noqa: E501
:rtype: H5PForm
"""
return util.deserialize_model(dikt, cls)
@property
def eid(self):
"""Gets the eid of this H5PForm.
Unique identifier (UUID) for the exercise. # noqa: E501
:return: The eid of this H5PForm.
:rtype: str
"""
return self._eid
@eid.setter
def eid(self, eid):
"""Sets the eid of this H5PForm.
Unique identifier (UUID) for the exercise. # noqa: E501
:param eid: The eid of this H5PForm.
:type eid: str
"""
self._eid = eid
@property
def exercise_type_path(self):
"""Gets the exercise_type_path of this H5PForm.
:return: The exercise_type_path of this H5PForm.
:rtype: ExerciseTypePath
"""
return self._exercise_type_path
@exercise_type_path.setter
def exercise_type_path(self, exercise_type_path):
"""Sets the exercise_type_path of this H5PForm.
:param exercise_type_path: The exercise_type_path of this H5PForm.
:type exercise_type_path: ExerciseTypePath
"""
self._exercise_type_path = exercise_type_path
@property
def lang(self):
"""Gets the lang of this H5PForm.
ISO 639-1 Language Code for the localization of exercise content. # noqa: E501
:return: The lang of this H5PForm.
:rtype: str
"""
return self._lang
@lang.setter
def lang(self, lang):
"""Sets the lang of this H5PForm.
ISO 639-1 Language Code for the localization of exercise content. # noqa: E501
:param lang: The lang of this H5PForm.
:type lang: str
"""
self._lang = lang
@property
def solution_indices(self):
"""Gets the solution_indices of this H5PForm.
Indices for the solutions that should be included in the download. # noqa: E501
:return: The solution_indices of this H5PForm.
:rtype: List[int]
"""
return self._solution_indices
@solution_indices.setter
def solution_indices(self, solution_indices):
"""Sets the solution_indices of this H5PForm.
Indices for the solutions that should be included in the download. # noqa: E501
:param solution_indices: The solution_indices of this H5PForm.
:type solution_indices: List[int]
"""
self._solution_indices = solution_indices
......@@ -355,6 +355,25 @@ paths:
description: JSON template for an interactive H5P exercise.
summary: Provides JSON templates for client-side H5P exercise layouts.
x-openapi-router-controller: openapi_server.controllers.default_controller
post:
operationId: mcserver_app_api_h5p_api_post
requestBody:
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/H5PForm'
required: true
responses:
"200":
content:
application/zip:
schema:
type: object
description: ZIP archive (with the H5P file extension) containing data for
a H5P exercise.
summary: Offers H5P exercises for download as ZIP archives (with the H5P file
extension).
x-openapi-router-controller: openapi_server.controllers.default_controller
/kwic:
post:
operationId: mcserver_app_api_kwic_api_post
......@@ -1259,8 +1278,40 @@ components:
- json
- pdf
- xml
- zip
example: pdf
type: string
H5PForm:
description: Metadata for the H5P exercise.
properties:
eid:
description: Unique identifier (UUID) for the exercise.
example: 12345678-1234-5678-1234-567812345678
type: string
exercise_type_path:
$ref: '#/components/schemas/ExerciseTypePath'
lang:
description: ISO 639-1 Language Code for the localization of exercise content.
example: en
type: string
solution_indices:
description: Indices for the solutions that should be included in the download.
items:
example: 0
type: integer
type: array
type: object
x-body-name: h5p_data
ExerciseTypePath:
description: Paths to the data directories for various H5P exercise types.
enum:
- drag_text
- fill_blanks
- mark_words
- multi_choice
- voc_list
example: drag_text
type: string
KwicForm:
description: Relevant parameters for creating a Keyword In Context view.
properties:
......