Add materials, exam images, storage quota, and API guide
Upload documents for AI context, exam images for Moodle questions, per-template storage limits, embedded images in XML export, and GUIA_API_Y_FLUJO.md with full endpoint documentation.
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import base64
|
||||
import json
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
from app.core.security import clean_text
|
||||
|
||||
|
||||
class MoodleXMLExporter:
|
||||
def export_xml(self, questions: list[Any]) -> str:
|
||||
def export_xml(self, questions: list[Any], image_map: dict[UUID, Any] | None = None) -> str:
|
||||
images = image_map or {}
|
||||
parts = ['<?xml version="1.0" encoding="UTF-8"?>', "<quiz>"]
|
||||
for index, question in enumerate(questions, start=1):
|
||||
parts.append(self._export_question(question, index))
|
||||
parts.append(self._export_question(question, index, images))
|
||||
parts.append("</quiz>")
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -17,6 +22,8 @@ class MoodleXMLExporter:
|
||||
blocks: list[str] = []
|
||||
for question in questions:
|
||||
lines = [self._attr(question, "statement")]
|
||||
if self._attr(question, "image_id"):
|
||||
lines.append(f"[Imagen adjunta: {self._attr(question, 'image_id')}]")
|
||||
lines.extend(self._attr(question, "correct_answers") or [])
|
||||
lines.extend(self._attr(question, "wrong_answers") or [])
|
||||
blocks.append("\n".join(clean_text(str(line)) for line in lines))
|
||||
@@ -26,19 +33,19 @@ class MoodleXMLExporter:
|
||||
payload = {"questions": [self._question_dict(question) for question in questions]}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
def _export_question(self, question: Any, index: int) -> str:
|
||||
def _export_question(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
|
||||
question_type = self._enum_value(self._attr(question, "question_type"))
|
||||
if question_type == "multichoice":
|
||||
return self._multichoice(question, index)
|
||||
return self._multichoice(question, index, image_map)
|
||||
if question_type == "truefalse":
|
||||
return self._truefalse(question, index)
|
||||
return self._truefalse(question, index, image_map)
|
||||
if question_type == "shortanswer":
|
||||
return self._shortanswer(question, index)
|
||||
return self._shortanswer(question, index, image_map)
|
||||
if question_type == "matching":
|
||||
return self._matching(question, index)
|
||||
return self._matching(question, index, image_map)
|
||||
raise ValueError(f"Unsupported Moodle question type: {question_type}")
|
||||
|
||||
def _multichoice(self, question: Any, index: int) -> str:
|
||||
def _multichoice(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
|
||||
correct_answers = self._attr(question, "correct_answers") or []
|
||||
wrong_answers = self._attr(question, "wrong_answers") or []
|
||||
options = self._attr(question, "options") or {}
|
||||
@@ -53,7 +60,7 @@ class MoodleXMLExporter:
|
||||
return "\n".join(
|
||||
[
|
||||
' <question type="multichoice">',
|
||||
self._common_header(question, index),
|
||||
*self._common_header(question, index, image_map),
|
||||
f" <single>{str(not multiple_correct).lower()}</single>",
|
||||
" <shuffleanswers>1</shuffleanswers>",
|
||||
*answers,
|
||||
@@ -61,32 +68,32 @@ class MoodleXMLExporter:
|
||||
]
|
||||
)
|
||||
|
||||
def _truefalse(self, question: Any, index: int) -> str:
|
||||
def _truefalse(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
|
||||
correct = (self._attr(question, "correct_answers") or ["true"])[0].lower()
|
||||
is_true = correct in {"true", "verdadero"}
|
||||
return "\n".join(
|
||||
[
|
||||
' <question type="truefalse">',
|
||||
self._common_header(question, index),
|
||||
*self._common_header(question, index, image_map),
|
||||
self._answer_xml("true", 100 if is_true else 0),
|
||||
self._answer_xml("false", 0 if is_true else 100),
|
||||
" </question>",
|
||||
]
|
||||
)
|
||||
|
||||
def _shortanswer(self, question: Any, index: int) -> str:
|
||||
def _shortanswer(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
|
||||
answers = [self._answer_xml(answer, 100) for answer in self._attr(question, "correct_answers")]
|
||||
return "\n".join(
|
||||
[
|
||||
' <question type="shortanswer">',
|
||||
self._common_header(question, index),
|
||||
*self._common_header(question, index, image_map),
|
||||
" <usecase>0</usecase>",
|
||||
*answers,
|
||||
" </question>",
|
||||
]
|
||||
)
|
||||
|
||||
def _matching(self, question: Any, index: int) -> str:
|
||||
def _matching(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
|
||||
subquestions = []
|
||||
for pair in self._attr(question, "matching_pairs") or []:
|
||||
prompt = pair.get("prompt") if isinstance(pair, dict) else pair.prompt
|
||||
@@ -106,27 +113,63 @@ class MoodleXMLExporter:
|
||||
return "\n".join(
|
||||
[
|
||||
' <question type="matching">',
|
||||
self._common_header(question, index),
|
||||
*self._common_header(question, index, image_map),
|
||||
*subquestions,
|
||||
" </question>",
|
||||
]
|
||||
)
|
||||
|
||||
def _common_header(self, question: Any, index: int) -> str:
|
||||
def _common_header(self, question: Any, index: int, image_map: dict[UUID, Any]) -> list[str]:
|
||||
statement = self._attr(question, "statement")
|
||||
name = clean_text(statement, max_length=80) or f"Pregunta {index}"
|
||||
return "\n".join(
|
||||
[
|
||||
" <name>",
|
||||
f" <text>{self._xml(name)}</text>",
|
||||
" </name>",
|
||||
' <questiontext format="html">',
|
||||
f" <text>{self._cdata(statement)}</text>",
|
||||
" </questiontext>",
|
||||
f" <defaultgrade>{float(self._attr(question, 'score') or 1.0):.2f}</defaultgrade>",
|
||||
" <generalfeedback format=\"html\"><text></text></generalfeedback>",
|
||||
]
|
||||
)
|
||||
return [
|
||||
" <name>",
|
||||
f" <text>{self._xml(name)}</text>",
|
||||
" </name>",
|
||||
' <questiontext format="html">',
|
||||
f" <text>{self._question_html(question, image_map)}</text>",
|
||||
" </questiontext>",
|
||||
*self._embedded_files(question, image_map),
|
||||
f" <defaultgrade>{float(self._attr(question, 'score') or 1.0):.2f}</defaultgrade>",
|
||||
' <generalfeedback format="html"><text></text></generalfeedback>',
|
||||
]
|
||||
|
||||
def _question_html(self, question: Any, image_map: dict[UUID, Any]) -> str:
|
||||
statement = html_escape(clean_text(str(self._attr(question, "statement"))))
|
||||
html_parts = [f"<p>{statement}</p>"]
|
||||
|
||||
image = self._resolve_image(question, image_map)
|
||||
if image is not None:
|
||||
alt = html_escape(clean_text(image.caption or image.original_filename, max_length=200))
|
||||
html_parts.append(
|
||||
f'<p><img src="@@PLUGINFILE@@/{image.stored_filename}" alt="{alt}" /></p>'
|
||||
)
|
||||
|
||||
return self._cdata("".join(html_parts))
|
||||
|
||||
def _embedded_files(self, question: Any, image_map: dict[UUID, Any]) -> list[str]:
|
||||
image = self._resolve_image(question, image_map)
|
||||
if image is None:
|
||||
return []
|
||||
|
||||
path = Path(image.storage_path)
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return [
|
||||
f' <file name="{self._xml(image.stored_filename)}" path="/" encoding="base64">',
|
||||
encoded,
|
||||
" </file>",
|
||||
]
|
||||
|
||||
def _resolve_image(self, question: Any, image_map: dict[UUID, Any]) -> Any | None:
|
||||
image_id = self._attr(question, "image_id")
|
||||
if image_id is None:
|
||||
return None
|
||||
if hasattr(question, "image") and question.image is not None:
|
||||
return question.image
|
||||
return image_map.get(image_id)
|
||||
|
||||
def _answer_xml(self, text: str, fraction: float) -> str:
|
||||
fraction_text = f"{fraction:.6g}"
|
||||
@@ -134,7 +177,7 @@ class MoodleXMLExporter:
|
||||
[
|
||||
f' <answer fraction="{fraction_text}" format="html">',
|
||||
f" <text>{self._xml(text)}</text>",
|
||||
" <feedback format=\"html\"><text></text></feedback>",
|
||||
' <feedback format="html"><text></text></feedback>',
|
||||
" </answer>",
|
||||
]
|
||||
)
|
||||
@@ -144,6 +187,7 @@ class MoodleXMLExporter:
|
||||
"id": str(self._attr(question, "id")) if self._attr(question, "id") else None,
|
||||
"question_type": self._enum_value(self._attr(question, "question_type")),
|
||||
"statement": self._attr(question, "statement"),
|
||||
"image_id": str(self._attr(question, "image_id")) if self._attr(question, "image_id") else None,
|
||||
"correct_answers": self._attr(question, "correct_answers") or [],
|
||||
"wrong_answers": self._attr(question, "wrong_answers") or [],
|
||||
"matching_pairs": self._attr(question, "matching_pairs") or [],
|
||||
@@ -162,5 +206,5 @@ class MoodleXMLExporter:
|
||||
return xml_escape(clean_text(str(value)), {'"': """, "'": "'"})
|
||||
|
||||
def _cdata(self, value: Any) -> str:
|
||||
text = clean_text(str(value)).replace("]]>", "]]]]><![CDATA[>")
|
||||
text = str(value).replace("]]>", "]]]]><![CDATA[>")
|
||||
return f"<![CDATA[{text}]]>"
|
||||
|
||||
Reference in New Issue
Block a user