feat: complete exam lifecycle — AI generation, submission, student session, and results
- Backend: AI generation fallbacks when OpenAI not configured, full exam submission saving all params (difficulty, rubric, entity, grading system, approval workflow) and creating linked question records per section - Backend: new exam session controller with get_session, autosave, submit, status, and results endpoints; student attempt/answer/score models - Backend: new controllers for entities, approval workflows, exam schedules - Frontend: exam session split-layout with passage panel, question types (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog - Frontend: results page with percentage score, per-answer breakdown table - Frontend: generation page dynamic dropdowns, full payload submission - Frontend: updated types for ExamSessionSection, ExamQuestion options Made-with: Cursor
This commit is contained in:
@@ -314,28 +314,183 @@ class AIController(http.Controller):
|
||||
_logger.exception("workbench publish failed")
|
||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ──
|
||||
@http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def ai_suggest_rubric_criteria(self, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
body = _get_json()
|
||||
name = body.get("name", "")
|
||||
skill = body.get("skill", "writing")
|
||||
exam_type = body.get("exam_type", "academic")
|
||||
levels = body.get("levels", ["A1", "A2", "B1", "B2", "C1", "C2"])
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
if not ai.client:
|
||||
raise RuntimeError("OpenAI not configured")
|
||||
|
||||
band_keys = ", ".join(f'"{lv}"' for lv in levels)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are an expert in English language assessment rubric design. "
|
||||
"Generate scoring criteria for a rubric. Return 3-6 criteria.\n\n"
|
||||
"Each criterion must have:\n"
|
||||
"- name: short name (e.g. 'Task Achievement')\n"
|
||||
"- weight: percentage weight (all weights must sum to 100)\n"
|
||||
"- descriptors: an object mapping ONLY these band levels to a 1-sentence description of expected performance at that level\n\n"
|
||||
f"The ONLY allowed band level keys are: {band_keys}\n\n"
|
||||
"Return ONLY this JSON structure:\n"
|
||||
'{"criteria": [{"name": "string", "weight": number, '
|
||||
'"descriptors": {"LEVEL": "one sentence description", ...}}]}'
|
||||
)},
|
||||
{"role": "user", "content": json.dumps({
|
||||
"rubric_name": name,
|
||||
"skill": skill,
|
||||
"exam_type": exam_type,
|
||||
"target_levels": levels,
|
||||
})},
|
||||
]
|
||||
result = ai.chat_json(messages, action="suggest_rubric_criteria")
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
_logger.warning("AI unavailable (%s), using template criteria for %s/%s", e, skill, exam_type)
|
||||
return _json_response({"criteria": self._fallback_criteria(skill, levels)})
|
||||
|
||||
@staticmethod
|
||||
def _fallback_criteria(skill, levels):
|
||||
"""Return pre-built criteria templates when OpenAI is unavailable."""
|
||||
def _desc(level_map, levels):
|
||||
return {lv: level_map.get(lv, "") for lv in levels}
|
||||
|
||||
templates = {
|
||||
"writing": [
|
||||
{"name": "Task Achievement", "weight": 25, "descriptors_map": {
|
||||
"C2": "Fully addresses all parts with a well-developed position",
|
||||
"C1": "Addresses all parts with a clear position throughout",
|
||||
"B2": "Addresses all parts, though some more fully than others",
|
||||
"B1": "Addresses the task only partially with limited development",
|
||||
"A2": "Barely responds to the task with very limited ideas",
|
||||
"A1": "Does not adequately address the task requirements",
|
||||
}},
|
||||
{"name": "Coherence & Cohesion", "weight": 25, "descriptors_map": {
|
||||
"C2": "Skillfully manages paragraphing with seamless cohesion",
|
||||
"C1": "Logically organizes information with clear progression",
|
||||
"B2": "Arranges information coherently with some cohesive devices",
|
||||
"B1": "Presents information with some organization but may lack clarity",
|
||||
"A2": "Limited ability to organize ideas; unclear progression",
|
||||
"A1": "No apparent logical organization of ideas",
|
||||
}},
|
||||
{"name": "Lexical Resource", "weight": 25, "descriptors_map": {
|
||||
"C2": "Uses a wide range of vocabulary with very natural and sophisticated control",
|
||||
"C1": "Uses a sufficient range of vocabulary to allow flexibility and precision",
|
||||
"B2": "Uses an adequate range of vocabulary for the task with some errors",
|
||||
"B1": "Uses a limited range of vocabulary with noticeable errors",
|
||||
"A2": "Uses only basic vocabulary with frequent errors in word choice",
|
||||
"A1": "Extremely limited vocabulary; barely able to convey meaning",
|
||||
}},
|
||||
{"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": {
|
||||
"C2": "Wide range of structures with full flexibility and accuracy",
|
||||
"C1": "Uses a variety of complex structures with good control",
|
||||
"B2": "Uses a mix of simple and complex sentences with some errors",
|
||||
"B1": "Attempts complex sentences but errors are frequent",
|
||||
"A2": "Uses only simple sentences with many errors",
|
||||
"A1": "Cannot use sentence forms except in memorized phrases",
|
||||
}},
|
||||
],
|
||||
"speaking": [
|
||||
{"name": "Fluency & Coherence", "weight": 25, "descriptors_map": {
|
||||
"C2": "Speaks effortlessly with natural flow and fully coherent speech",
|
||||
"C1": "Speaks at length without noticeable effort or loss of coherence",
|
||||
"B2": "Speaks with some hesitation but maintains coherent speech",
|
||||
"B1": "Can keep going but pauses frequently to plan and correct",
|
||||
"A2": "Produces simple utterances with long pauses",
|
||||
"A1": "Speech is extremely slow with very long pauses",
|
||||
}},
|
||||
{"name": "Lexical Resource", "weight": 25, "descriptors_map": {
|
||||
"C2": "Uses vocabulary with full flexibility and precision in all topics",
|
||||
"C1": "Uses vocabulary flexibly to discuss a variety of topics",
|
||||
"B2": "Has a wide enough vocabulary to discuss topics at length",
|
||||
"B1": "Uses sufficient vocabulary for familiar topics",
|
||||
"A2": "Uses basic vocabulary for personal information and routine situations",
|
||||
"A1": "Can only produce isolated words and memorized phrases",
|
||||
}},
|
||||
{"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": {
|
||||
"C2": "Maintains consistent use of a wide range of accurate structures",
|
||||
"C1": "Uses a wide range of structures with a majority of error-free sentences",
|
||||
"B2": "Uses a range of structures with reasonable accuracy",
|
||||
"B1": "Produces basic sentence forms with reasonable accuracy",
|
||||
"A2": "Produces basic sentences with frequent errors",
|
||||
"A1": "Cannot produce basic sentence forms",
|
||||
}},
|
||||
{"name": "Pronunciation", "weight": 25, "descriptors_map": {
|
||||
"C2": "Is effortless to understand with natural pronunciation features",
|
||||
"C1": "Uses a wide range of pronunciation features with fine control",
|
||||
"B2": "Is generally easy to understand with some L1 influence",
|
||||
"B1": "Shows some effective use of features but may be inconsistent",
|
||||
"A2": "Pronunciation is generally understood but often faulty",
|
||||
"A1": "Speech is often unintelligible due to pronunciation errors",
|
||||
}},
|
||||
],
|
||||
}
|
||||
|
||||
base = templates.get(skill, templates["writing"])
|
||||
return [
|
||||
{
|
||||
"name": c["name"],
|
||||
"weight": c["weight"],
|
||||
"descriptors": _desc(c["descriptors_map"], levels),
|
||||
}
|
||||
for c in base
|
||||
]
|
||||
|
||||
# ── Exam generation — GenerationPage.tsx ──
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def exam_generate(self, module, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
has_ai = bool(ai.client)
|
||||
except Exception:
|
||||
ai, has_ai = None, False
|
||||
|
||||
try:
|
||||
if body.get("generate_passage"):
|
||||
if has_ai:
|
||||
return self._generate_passage(ai, body)
|
||||
return _json_response(self._fallback_passage(body))
|
||||
if body.get("generate_instructions"):
|
||||
if has_ai:
|
||||
return self._generate_writing_instructions(ai, body)
|
||||
return _json_response(self._fallback_writing_instructions(body))
|
||||
if body.get("generate_script"):
|
||||
if has_ai:
|
||||
return self._generate_speaking_script(ai, body)
|
||||
return _json_response(self._fallback_speaking_script(body))
|
||||
if body.get("generate_context"):
|
||||
if has_ai:
|
||||
return self._generate_listening_context(ai, body)
|
||||
return _json_response(self._fallback_listening_context(body))
|
||||
if body.get("generate_exercises"):
|
||||
if has_ai:
|
||||
return self._generate_exercises(ai, module, body)
|
||||
return _json_response(self._fallback_exercises(module, body))
|
||||
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
topic = body.get("topic", "")
|
||||
count = body.get("count") or body.get("question_count") or 5
|
||||
if has_ai:
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
||||
@@ -346,6 +501,7 @@ class AIController(http.Controller):
|
||||
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
||||
return _json_response(self._fallback_questions(module, body))
|
||||
except Exception as e:
|
||||
_logger.exception("exam_generate %s failed: %s", module, e)
|
||||
return _json_response({"questions": [], "error": str(e)}, 500)
|
||||
@@ -449,9 +605,314 @@ class AIController(http.Controller):
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
|
||||
|
||||
# ── Fallback generators (no OpenAI needed) ──
|
||||
|
||||
@staticmethod
|
||||
def _fallback_passage(body):
|
||||
topic = body.get("topic", "travel")
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
templates = {
|
||||
"travel": {
|
||||
"title": "The Rise of Sustainable Tourism",
|
||||
"passage": (
|
||||
"In recent years, sustainable tourism has emerged as a powerful movement reshaping how "
|
||||
"people explore the world. Unlike traditional mass tourism, which often prioritises "
|
||||
"convenience and cost over environmental impact, sustainable tourism encourages travellers "
|
||||
"to consider their ecological footprint and cultural sensitivity.\n\n"
|
||||
"The concept gained significant traction after international organisations highlighted the "
|
||||
"devastating effects of unchecked tourism on fragile ecosystems. Coral reefs in Southeast "
|
||||
"Asia, ancient ruins in South America, and wildlife reserves in Africa have all suffered "
|
||||
"from overcrowding, pollution, and habitat destruction caused by the influx of visitors.\n\n"
|
||||
"Governments and local communities have responded by implementing measures such as visitor "
|
||||
"caps, eco-certification programmes, and community-based tourism initiatives. In Bhutan, "
|
||||
"for example, the government charges a daily sustainable development fee to limit tourist "
|
||||
"numbers while funding conservation efforts and education.\n\n"
|
||||
"Tour operators have also adapted their business models. Many now offer carbon-offset "
|
||||
"programmes, partner with local artisans and guides, and design itineraries that minimise "
|
||||
"environmental disruption. Accommodation providers have invested in solar energy, rainwater "
|
||||
"harvesting, and waste-reduction systems.\n\n"
|
||||
"Despite these positive developments, challenges remain. Critics argue that sustainable "
|
||||
"tourism can be exclusionary, pricing out budget travellers and local residents. Others "
|
||||
"point out that certification schemes vary widely in rigour and transparency. Nevertheless, "
|
||||
"the growing awareness among travellers suggests that the industry is moving in the right "
|
||||
"direction, balancing economic growth with environmental stewardship."
|
||||
),
|
||||
},
|
||||
"technology": {
|
||||
"title": "Artificial Intelligence in Everyday Life",
|
||||
"passage": (
|
||||
"Artificial intelligence, once confined to research laboratories and science fiction, has "
|
||||
"become an integral part of daily life. From voice-activated assistants on smartphones to "
|
||||
"recommendation algorithms on streaming platforms, AI systems now influence many of the "
|
||||
"choices people make without them even realising it.\n\n"
|
||||
"One of the most visible applications of AI is in healthcare. Machine learning algorithms "
|
||||
"can now analyse medical images with remarkable accuracy, sometimes identifying conditions "
|
||||
"such as early-stage cancers that human radiologists might miss. Hospitals around the world "
|
||||
"are adopting AI-powered tools for patient triage, drug discovery, and personalised "
|
||||
"treatment planning.\n\n"
|
||||
"In education, AI-driven platforms adapt learning content to individual student needs. These "
|
||||
"systems monitor a learner's progress and adjust the difficulty and style of materials "
|
||||
"accordingly, providing a customised experience that traditional classroom settings often "
|
||||
"cannot match.\n\n"
|
||||
"However, the rapid adoption of AI has raised important ethical questions. Issues of data "
|
||||
"privacy, algorithmic bias, and job displacement have sparked intense debate among "
|
||||
"policymakers, technologists, and the general public. There are growing calls for "
|
||||
"regulations that ensure AI systems are transparent, fair, and accountable.\n\n"
|
||||
"As AI continues to evolve, its impact on society will only deepen. The challenge lies in "
|
||||
"harnessing its potential for good while mitigating the risks it poses to privacy, "
|
||||
"employment, and social equity."
|
||||
),
|
||||
},
|
||||
}
|
||||
t = topic.lower().strip()
|
||||
if t in templates:
|
||||
return templates[t]
|
||||
default = templates["travel"]
|
||||
default["title"] = f"{topic.title()} — A {difficulty} Level Reading Passage"
|
||||
default["passage"] = default["passage"].replace("sustainable tourism", topic.lower())
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _fallback_writing_instructions(body):
|
||||
topic = body.get("topic", "general")
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
task_type = body.get("task_type", "essay")
|
||||
templates = {
|
||||
"essay": (
|
||||
f"Write an essay of at least 250 words on the following topic:\n\n"
|
||||
f"\"{topic.title()}\"\n\n"
|
||||
"You should:\n"
|
||||
"• present a clear position on the issue\n"
|
||||
"• support your arguments with relevant examples\n"
|
||||
"• organise your ideas logically with clear paragraphing\n"
|
||||
"• use a range of vocabulary and grammatical structures\n\n"
|
||||
"Write at least 250 words."
|
||||
),
|
||||
"report": (
|
||||
f"The chart/graph below shows information about {topic.lower()}.\n\n"
|
||||
"Summarise the information by selecting and reporting the main features, "
|
||||
"and make comparisons where relevant.\n\n"
|
||||
"Write at least 150 words."
|
||||
),
|
||||
"letter": (
|
||||
f"You recently had an experience related to {topic.lower()}. "
|
||||
"Write a letter to a friend describing what happened.\n\n"
|
||||
"In your letter:\n"
|
||||
"• explain the situation\n"
|
||||
"• describe how you felt\n"
|
||||
"• suggest what your friend should do in a similar situation\n\n"
|
||||
"Write at least 150 words. You do NOT need to write any addresses."
|
||||
),
|
||||
}
|
||||
return {"instructions": templates.get(task_type, templates["essay"])}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_speaking_script(body):
|
||||
part = body.get("part", "speaking_1")
|
||||
topics = body.get("topics", [])
|
||||
topic_str = ", ".join(t for t in topics if t) or "general conversation"
|
||||
|
||||
scripts = {
|
||||
"speaking_1": (
|
||||
f"Part 1 — Introduction and Interview\n\n"
|
||||
f"Examiner: Good morning/afternoon. My name is [Examiner]. "
|
||||
f"Can you tell me your full name, please?\n\n"
|
||||
f"Now I'd like to ask you some questions about {topic_str}.\n\n"
|
||||
f"1. Can you tell me about your experience with {topic_str}?\n"
|
||||
f"2. How important is {topic_str} in your daily life?\n"
|
||||
f"3. Has your interest in {topic_str} changed over the years?\n"
|
||||
f"4. What do most people in your country think about {topic_str}?\n"
|
||||
),
|
||||
"speaking_2": (
|
||||
f"Part 2 — Individual Long Turn\n\n"
|
||||
f"Examiner: Now I'm going to give you a topic, and I'd like you to talk "
|
||||
f"about it for one to two minutes. You have one minute to prepare.\n\n"
|
||||
f"Describe a time when you experienced something related to {topic_str}.\n\n"
|
||||
f"You should say:\n"
|
||||
f"• what happened\n"
|
||||
f"• when and where it happened\n"
|
||||
f"• who was involved\n"
|
||||
f"and explain how you felt about it.\n"
|
||||
),
|
||||
"speaking_3": (
|
||||
f"Part 3 — Two-way Discussion\n\n"
|
||||
f"Examiner: We've been talking about {topic_str}, and now I'd like to "
|
||||
f"discuss some broader questions related to this topic.\n\n"
|
||||
f"1. How has {topic_str} changed in your country in recent years?\n"
|
||||
f"2. Do you think {topic_str} will be more or less important in the future? Why?\n"
|
||||
f"3. What are the advantages and disadvantages of {topic_str}?\n"
|
||||
f"4. How might governments address challenges related to {topic_str}?\n"
|
||||
),
|
||||
}
|
||||
return {"script": scripts.get(part, scripts["speaking_1"])}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_listening_context(body):
|
||||
topic = body.get("topic", "everyday life")
|
||||
section_type = body.get("section_type", "social_conversation")
|
||||
|
||||
transcripts = {
|
||||
"social_conversation": (
|
||||
f"[A conversation between two friends about {topic}]\n\n"
|
||||
"Speaker A: Hi! I haven't seen you in ages. How have you been?\n\n"
|
||||
"Speaker B: I've been great, thanks. Actually, I've been quite busy lately "
|
||||
f"because I've been working on something related to {topic}.\n\n"
|
||||
"Speaker A: Oh really? That sounds interesting. Tell me more about it.\n\n"
|
||||
f"Speaker B: Well, it started about three months ago when I decided to "
|
||||
f"explore {topic} more seriously. I joined a local group and we meet every "
|
||||
f"Tuesday evening to discuss different aspects of it.\n\n"
|
||||
"Speaker A: That sounds fantastic. Have you learned a lot?\n\n"
|
||||
"Speaker B: Absolutely. I've discovered that there's much more to it than "
|
||||
"I originally thought. For instance, did you know that most experts "
|
||||
"recommend starting with the basics before moving to advanced topics?\n\n"
|
||||
"Speaker A: I didn't know that. Maybe I should join your group too.\n\n"
|
||||
"Speaker B: You'd be very welcome! The next meeting is this Tuesday at "
|
||||
"seven o'clock in the community centre on Park Road."
|
||||
),
|
||||
"academic_lecture": (
|
||||
f"[An academic lecture about {topic}]\n\n"
|
||||
f"Professor: Good morning, everyone. Today we'll be discussing {topic} "
|
||||
"and its significance in the modern world.\n\n"
|
||||
f"As you may already know, research into {topic} has expanded significantly "
|
||||
"over the past decade. Recent studies have shown that understanding this "
|
||||
"area can have far-reaching implications for both theory and practice.\n\n"
|
||||
"Let me begin by outlining the three main approaches that researchers "
|
||||
"have taken. The first approach focuses on quantitative analysis, "
|
||||
"using large datasets to identify patterns. The second emphasises "
|
||||
"qualitative methods, drawing on interviews and case studies. The third, "
|
||||
"and perhaps most promising, combines both methodologies.\n\n"
|
||||
"Now, I'd like to draw your attention to a landmark study published "
|
||||
"in 2023 by Dr. Chen and her colleagues. Their findings suggested that "
|
||||
"a combined approach yielded results that were 40% more reliable than "
|
||||
"either method used in isolation."
|
||||
),
|
||||
}
|
||||
return {"context": transcripts.get(section_type, transcripts["social_conversation"])}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_exercises(module, body):
|
||||
exercise_types = body.get("exercise_types", ["mcq"])
|
||||
type_counts = body.get("type_counts", {})
|
||||
default_count = body.get("count_per_type", 5)
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
questions = []
|
||||
|
||||
q_templates = {
|
||||
"mcq": lambda i: {
|
||||
"type": "mcq",
|
||||
"instructions": "Choose the correct answer for each question.",
|
||||
"prompt": f"According to the passage, what is the main idea discussed in paragraph {i + 1}?",
|
||||
"options": [
|
||||
"The historical background of the topic",
|
||||
"The current challenges being faced",
|
||||
"Future predictions and recommendations",
|
||||
"A comparison of different approaches",
|
||||
],
|
||||
"correct_answer": "The current challenges being faced",
|
||||
"explanation": "The paragraph primarily discusses the challenges faced in this area.",
|
||||
"marks": 1,
|
||||
},
|
||||
"true_false": lambda i: {
|
||||
"type": "true_false",
|
||||
"instructions": "Do the following statements agree with the information given in the passage? Write TRUE, FALSE, or NOT GIVEN.",
|
||||
"prompt": [
|
||||
"The author supports the idea that the topic will become more important.",
|
||||
"Research in this area began more than fifty years ago.",
|
||||
"All experts agree on the best approach to address this issue.",
|
||||
"The text mentions several countries where changes have occurred.",
|
||||
"The writer believes that current measures are sufficient.",
|
||||
][i % 5],
|
||||
"options": ["TRUE", "FALSE", "NOT GIVEN"],
|
||||
"correct_answer": ["TRUE", "FALSE", "NOT GIVEN", "TRUE", "FALSE"][i % 5],
|
||||
"explanation": "Based on the information provided in the passage.",
|
||||
"marks": 1,
|
||||
},
|
||||
"fill_blanks": lambda i: {
|
||||
"type": "fill_blanks",
|
||||
"instructions": "Complete the sentences below. Choose NO MORE THAN TWO WORDS from the passage for each answer.",
|
||||
"prompt": [
|
||||
"The main factor contributing to changes in this area is ___.",
|
||||
"Experts recommend that people should first focus on ___.",
|
||||
"The study found that combined methods were ___ more effective.",
|
||||
"Local communities have responded by implementing ___.",
|
||||
"The primary concern raised by critics is the issue of ___.",
|
||||
][i % 5],
|
||||
"options": [],
|
||||
"correct_answer": [
|
||||
"growing awareness", "basic principles", "significantly",
|
||||
"new measures", "accessibility",
|
||||
][i % 5],
|
||||
"explanation": "This answer can be found in the relevant paragraph of the passage.",
|
||||
"marks": 1,
|
||||
},
|
||||
"matching_headings": lambda i: {
|
||||
"type": "matching_headings",
|
||||
"instructions": "Choose the correct heading for each paragraph from the list below.",
|
||||
"prompt": f"Paragraph {i + 1}",
|
||||
"options": [
|
||||
"A. An overview of the current situation",
|
||||
"B. Historical development",
|
||||
"C. Future challenges and opportunities",
|
||||
"D. Government responses",
|
||||
"E. Expert opinions and analysis",
|
||||
],
|
||||
"correct_answer": ["A", "B", "C", "D", "E"][i % 5],
|
||||
"explanation": f"Paragraph {i + 1} primarily deals with this topic.",
|
||||
"marks": 1,
|
||||
},
|
||||
"paragraph_match": lambda i: {
|
||||
"type": "paragraph_match",
|
||||
"instructions": "Which paragraph contains the following information?",
|
||||
"prompt": [
|
||||
"a reference to research findings",
|
||||
"a mention of financial concerns",
|
||||
"an example from a specific country",
|
||||
"a description of community initiatives",
|
||||
"a prediction about the future",
|
||||
][i % 5],
|
||||
"options": ["A", "B", "C", "D", "E"],
|
||||
"correct_answer": ["C", "D", "B", "A", "E"][i % 5],
|
||||
"explanation": "This information can be found in the specified paragraph.",
|
||||
"marks": 1,
|
||||
},
|
||||
}
|
||||
|
||||
for et in (exercise_types or ["mcq"]):
|
||||
count = int(type_counts.get(et, default_count))
|
||||
gen_fn = q_templates.get(et, q_templates["mcq"])
|
||||
for i in range(count):
|
||||
q = gen_fn(i)
|
||||
q["difficulty"] = difficulty
|
||||
questions.append(q)
|
||||
|
||||
return {"questions": questions}
|
||||
|
||||
@staticmethod
|
||||
def _fallback_questions(module, body):
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
count = int(body.get("count") or body.get("question_count") or 5)
|
||||
questions = []
|
||||
for i in range(count):
|
||||
questions.append({
|
||||
"type": "mcq",
|
||||
"prompt": f"Sample {module} question {i + 1} at {difficulty} level. "
|
||||
"Which of the following best describes the main concept?",
|
||||
"options": ["Option A", "Option B", "Option C", "Option D"],
|
||||
"correct_answer": "Option A",
|
||||
"explanation": f"This is a sample question for the {module} module.",
|
||||
"difficulty": difficulty,
|
||||
"marks": 1,
|
||||
})
|
||||
return {"questions": questions}
|
||||
|
||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def generation_submit(self, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
body = _get_json()
|
||||
try:
|
||||
title = body.get("title", "").strip()
|
||||
@@ -461,6 +922,21 @@ class AIController(http.Controller):
|
||||
label = body.get("label", "")
|
||||
modules = body.get("modules", {})
|
||||
skip_approval = body.get("skip_approval", False)
|
||||
exam_mode = body.get("exam_mode", "official")
|
||||
structure_id = body.get("structure_id")
|
||||
|
||||
first_mod = next(iter(modules.values()), {}) if modules else {}
|
||||
entity_val = first_mod.get("entity", "none")
|
||||
entity_id = int(entity_val) if entity_val and entity_val != "none" else False
|
||||
rubric_raw = first_mod.get("rubricId", "")
|
||||
rubric_id = False
|
||||
if rubric_raw and rubric_raw.startswith("rubric-"):
|
||||
try:
|
||||
rubric_id = int(rubric_raw.split("-", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
workflow_val = first_mod.get("approvalWorkflow", "none")
|
||||
workflow_id = int(workflow_val) if workflow_val and workflow_val != "none" else 0
|
||||
|
||||
template_id = False
|
||||
try:
|
||||
@@ -482,35 +958,157 @@ class AIController(http.Controller):
|
||||
except KeyError:
|
||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||
|
||||
exam = Exam.sudo().create({
|
||||
exam_vals = {
|
||||
"title": title,
|
||||
"label": label,
|
||||
"exam_mode": exam_mode,
|
||||
"teacher_id": request.env.user.id,
|
||||
"template_id": template_id,
|
||||
"status": "published" if skip_approval else "draft",
|
||||
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
|
||||
"total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()),
|
||||
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
|
||||
})
|
||||
"grading_system": first_mod.get("gradingSystem", "ielts"),
|
||||
"access_type": first_mod.get("accessType", "private"),
|
||||
"approval_workflow_id": workflow_id,
|
||||
}
|
||||
if entity_id:
|
||||
exam_vals["entity_id"] = entity_id
|
||||
if rubric_id:
|
||||
exam_vals["rubric_id"] = rubric_id
|
||||
if structure_id:
|
||||
exam_vals["structure_id"] = int(structure_id)
|
||||
|
||||
exam = Exam.sudo().create(exam_vals)
|
||||
|
||||
Section = request.env["encoach.exam.custom.section"].sudo()
|
||||
Question = request.env["encoach.question"].sudo()
|
||||
|
||||
CEFR_TO_DIFFICULTY = {
|
||||
"A1": "easy", "A2": "easy",
|
||||
"B1": "medium", "B2": "medium",
|
||||
"C1": "hard", "C2": "hard",
|
||||
}
|
||||
QUESTION_TYPE_MAP = {
|
||||
"mcq": "mcq", "true_false": "tfng", "fill_blanks": "gap_fill",
|
||||
"matching_headings": "heading_matching", "paragraph_match": "matching",
|
||||
"short_answer": "short_answer", "summary_completion": "summary_completion",
|
||||
"multiple_choice": "mcq", "sentence_completion": "gap_fill",
|
||||
"matching_information": "matching", "note_completion": "note_completion",
|
||||
"form_completion": "form_completion", "map_labelling": "map_labelling",
|
||||
}
|
||||
|
||||
try:
|
||||
Section = request.env["encoach.exam.custom.section"]
|
||||
seq = 10
|
||||
total_questions = 0
|
||||
for mod_key, mod_data in modules.items():
|
||||
Section.sudo().create({
|
||||
difficulty_list = mod_data.get("difficulty", ["B2"])
|
||||
cefr_level = difficulty_list[0] if isinstance(difficulty_list, list) and difficulty_list else "B2"
|
||||
q_difficulty = CEFR_TO_DIFFICULTY.get(cefr_level, "medium")
|
||||
|
||||
section = Section.create({
|
||||
"exam_id": exam.id,
|
||||
"title": mod_key.capitalize(),
|
||||
"skill": mod_key,
|
||||
"difficulty": cefr_level,
|
||||
"time_limit_min": mod_data.get("timer", 0),
|
||||
"scoring_method": "auto",
|
||||
"total_marks": float(mod_data.get("totalMarks", 0)),
|
||||
"scoring_method": "rubric" if mod_key in ("writing", "speaking") else "auto",
|
||||
"sequence": seq,
|
||||
"content_json": json.dumps({
|
||||
k: mod_data[k] for k in ("passages", "sections", "tasks", "parts")
|
||||
if k in mod_data and mod_data[k]
|
||||
}),
|
||||
})
|
||||
seq += 10
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
question_ids = []
|
||||
|
||||
passages = mod_data.get("passages") or []
|
||||
for p_idx, passage in enumerate(passages):
|
||||
if passage.get("text"):
|
||||
section.sudo().write({"passage_text": passage["text"]})
|
||||
for ex in (passage.get("exercises") or []):
|
||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||
opts = ex.get("options", [])
|
||||
q = Question.create({
|
||||
"skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading",
|
||||
"source_type": "passage",
|
||||
"question_type": q_type,
|
||||
"stem": ex.get("prompt", "") or ex.get("instructions", ""),
|
||||
"options": json.dumps(opts) if opts else "[]",
|
||||
"correct_answer": ex.get("correct_answer", ""),
|
||||
"marks": float(ex.get("marks", 1)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
sections_data = mod_data.get("sections") or []
|
||||
for s_data in sections_data:
|
||||
if s_data.get("context"):
|
||||
section.sudo().write({"passage_text": s_data["context"]})
|
||||
for ex in (s_data.get("exercises") or []):
|
||||
q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq")
|
||||
opts = ex.get("options", [])
|
||||
q = Question.create({
|
||||
"skill": "listening",
|
||||
"source_type": "audio",
|
||||
"question_type": q_type,
|
||||
"stem": ex.get("prompt", "") or ex.get("instructions", ""),
|
||||
"options": json.dumps(opts) if opts else "[]",
|
||||
"correct_answer": ex.get("correct_answer", ""),
|
||||
"marks": float(ex.get("marks", 1)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
tasks = mod_data.get("tasks") or []
|
||||
for t_idx, task in enumerate(tasks):
|
||||
q = Question.create({
|
||||
"skill": "writing",
|
||||
"source_type": "writing_prompt",
|
||||
"question_type": "short_answer",
|
||||
"stem": task.get("instructions", f"Writing Task {t_idx + 1}"),
|
||||
"options": "[]",
|
||||
"correct_answer": "",
|
||||
"marks": float(task.get("marks", 0)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
parts = mod_data.get("parts") or []
|
||||
for p_idx, part in enumerate(parts):
|
||||
q = Question.create({
|
||||
"skill": "speaking",
|
||||
"source_type": "speaking_card",
|
||||
"question_type": "short_answer",
|
||||
"stem": part.get("script", f"Speaking Part {p_idx + 1}"),
|
||||
"options": "[]",
|
||||
"correct_answer": "",
|
||||
"marks": float(part.get("marks", 0)),
|
||||
"difficulty": q_difficulty,
|
||||
"status": "active",
|
||||
"ai_generated": True,
|
||||
})
|
||||
question_ids.append(q.id)
|
||||
|
||||
if question_ids:
|
||||
section.sudo().write({
|
||||
"question_ids": [(6, 0, question_ids)],
|
||||
"question_count": len(question_ids),
|
||||
})
|
||||
total_questions += len(question_ids)
|
||||
|
||||
return _json_response({
|
||||
"exam_id": exam.id,
|
||||
"status": exam.status,
|
||||
"template_id": template_id,
|
||||
"total_questions": total_questions,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception("generation submit failed")
|
||||
@@ -532,8 +1130,13 @@ class AIController(http.Controller):
|
||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def exam_generate_save(self, module, **kw):
|
||||
from odoo.addons.encoach_api.controllers.base import validate_token
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return _json_response({"error": "Authentication required"}, 401)
|
||||
request.update_env(user=user.id)
|
||||
body = _get_json()
|
||||
questions = body.get("questions", [])
|
||||
saved = 0
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import ai_settings
|
||||
from . import ai_log
|
||||
from . import constants
|
||||
|
||||
13
custom_addons/encoach_ai/models/constants.py
Normal file
13
custom_addons/encoach_ai/models/constants.py
Normal file
@@ -0,0 +1,13 @@
|
||||
CEFR_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||||
|
||||
GPT_MODELS = {
|
||||
'default': 'gpt-4o',
|
||||
'fast': 'gpt-4o-mini',
|
||||
}
|
||||
|
||||
TEMPERATURE = 0.7
|
||||
|
||||
TOPICS = [
|
||||
'Education', 'Technology', 'Health', 'Environment', 'Culture',
|
||||
'Travel', 'Science', 'Business', 'Sports', 'Society',
|
||||
]
|
||||
@@ -341,3 +341,6 @@ class OpenAIService:
|
||||
messages.append({"role": "user", "content": brief_text})
|
||||
|
||||
return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096)
|
||||
|
||||
|
||||
EncoachOpenAIService = OpenAIService
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<data noupdate="1">
|
||||
<!-- Global permissions (PermissionType from permissions.ts) -->
|
||||
<record id="perm_viewCorporate" model="encoach.permission">
|
||||
<field name="code">viewCorporate</field>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
'data/ielts_templates.xml',
|
||||
'data/sample_passages.xml',
|
||||
'data/sample_questions.xml',
|
||||
'data/ir_cron_schedule.xml',
|
||||
'views/exam_template_views.xml',
|
||||
'views/passage_views.xml',
|
||||
'views/audio_file_views.xml',
|
||||
|
||||
@@ -3,4 +3,8 @@ from . import ielts_exam
|
||||
from . import custom_exam
|
||||
from . import exam_structures
|
||||
from . import assignments
|
||||
from . import exam_schedules
|
||||
from . import rubrics
|
||||
from . import approval_workflows
|
||||
from . import entities
|
||||
from . import exam_session
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cr():
|
||||
return request.env.cr
|
||||
|
||||
|
||||
def _user_name(cr, user_id):
|
||||
if not user_id:
|
||||
return ''
|
||||
cr.execute(
|
||||
"SELECT rp.name FROM res_users ru "
|
||||
"JOIN res_partner rp ON rp.id = ru.partner_id "
|
||||
"WHERE ru.id = %s", (user_id,))
|
||||
row = cr.fetchone()
|
||||
return row[0] if row else ''
|
||||
|
||||
|
||||
class ApprovalWorkflowController(http.Controller):
|
||||
|
||||
@http.route('/api/approval-workflows', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_workflows(self, **kw):
|
||||
try:
|
||||
cr = _cr()
|
||||
cr.execute(
|
||||
"SELECT id, name, type, allow_bypass, entity_id, create_date "
|
||||
"FROM encoach_approval_workflow ORDER BY create_date DESC")
|
||||
workflows = cr.fetchall()
|
||||
|
||||
items = []
|
||||
for wf_id, name, wtype, bypass, entity_id, created in workflows:
|
||||
cr.execute(
|
||||
"SELECT id, sequence, approver_id, status, comment, "
|
||||
"auto_escalate, max_days, acted_at "
|
||||
"FROM encoach_approval_stage WHERE workflow_id = %s "
|
||||
"ORDER BY sequence ASC", (wf_id,))
|
||||
steps = []
|
||||
for sid, seq, approver_id, status, comment, escalate, max_d, acted in cr.fetchall():
|
||||
steps.append({
|
||||
'id': sid,
|
||||
'sequence': seq,
|
||||
'approver_id': approver_id,
|
||||
'approver_name': _user_name(cr, approver_id),
|
||||
'status': status or 'pending',
|
||||
'comment': comment or '',
|
||||
'auto_escalate': bool(escalate),
|
||||
'max_days': max_d or 0,
|
||||
'acted_at': acted.isoformat() if acted else None,
|
||||
})
|
||||
|
||||
entity_name = None
|
||||
if entity_id:
|
||||
cr.execute("SELECT name FROM encoach_entity WHERE id=%s", (entity_id,))
|
||||
row = cr.fetchone()
|
||||
entity_name = row[0] if row else None
|
||||
|
||||
items.append({
|
||||
'id': wf_id,
|
||||
'name': name,
|
||||
'type': wtype or '',
|
||||
'entity_id': entity_id,
|
||||
'entity_name': entity_name,
|
||||
'allow_bypass': bool(bypass),
|
||||
'steps': steps,
|
||||
'created': created.strftime('%Y-%m-%d') if created else '',
|
||||
})
|
||||
|
||||
return _json_response({'items': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
_logger.exception('list workflows failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/approval-workflows', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_workflow(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
name = body.get('name', '').strip()
|
||||
if not name:
|
||||
return _error_response('name is required', 400)
|
||||
|
||||
cr = _cr()
|
||||
now = datetime.now()
|
||||
uid = request.env.uid
|
||||
|
||||
cr.execute(
|
||||
"INSERT INTO encoach_approval_workflow "
|
||||
"(name, type, allow_bypass, create_uid, write_uid, create_date, write_date) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(name, body.get('type', 'custom'), body.get('allow_bypass', False),
|
||||
uid, uid, now, now))
|
||||
wf_id = cr.fetchone()[0]
|
||||
|
||||
for i, step in enumerate(body.get('steps', [])):
|
||||
approver_id = step.get('approver_id')
|
||||
if approver_id:
|
||||
cr.execute(
|
||||
"INSERT INTO encoach_approval_stage "
|
||||
"(workflow_id, sequence, approver_id, status, auto_escalate, max_days, "
|
||||
"create_uid, write_uid, create_date, write_date) "
|
||||
"VALUES (%s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s)",
|
||||
(wf_id, (i + 1) * 10, int(approver_id),
|
||||
step.get('auto_escalate', True), step.get('max_days', 3),
|
||||
uid, uid, now, now))
|
||||
|
||||
cr.execute("SELECT id, name, type FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
|
||||
row = cr.fetchone()
|
||||
return _json_response({'id': row[0], 'name': row[1], 'type': row[2]}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception('create workflow failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/approval-workflows/<int:wf_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_workflow(self, wf_id, **kw):
|
||||
try:
|
||||
cr = _cr()
|
||||
cr.execute("DELETE FROM encoach_approval_request WHERE workflow_id=%s", (wf_id,))
|
||||
cr.execute("DELETE FROM encoach_approval_stage WHERE workflow_id=%s", (wf_id,))
|
||||
cr.execute("DELETE FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
_logger.exception('delete workflow failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
# ── Approval Requests ──
|
||||
|
||||
@http.route('/api/approval-requests', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_requests(self, **kw):
|
||||
try:
|
||||
cr = _cr()
|
||||
state_filter = kw.get('state')
|
||||
where = ""
|
||||
params = []
|
||||
if state_filter:
|
||||
where = "WHERE ar.state = %s"
|
||||
params.append(state_filter)
|
||||
|
||||
cr.execute(f"""
|
||||
SELECT ar.id, ar.workflow_id, aw.name as wf_name,
|
||||
ar.res_model, ar.res_id, ar.state,
|
||||
ar.requester_id, ar.current_stage_id,
|
||||
ar.bypass_reason, ar.created_at
|
||||
FROM encoach_approval_request ar
|
||||
LEFT JOIN encoach_approval_workflow aw ON aw.id = ar.workflow_id
|
||||
{where}
|
||||
ORDER BY ar.created_at DESC NULLS LAST
|
||||
""", params)
|
||||
|
||||
items = []
|
||||
for row in cr.fetchall():
|
||||
rid, wf_id, wf_name, model, res_id, state, req_id, stage_id, bypass, created = row
|
||||
stage_seq = None
|
||||
if stage_id:
|
||||
cr.execute("SELECT sequence FROM encoach_approval_stage WHERE id=%s", (stage_id,))
|
||||
s = cr.fetchone()
|
||||
stage_seq = s[0] if s else None
|
||||
|
||||
items.append({
|
||||
'id': rid,
|
||||
'workflow_id': wf_id,
|
||||
'workflow_name': wf_name or '',
|
||||
'res_model': model or '',
|
||||
'res_id': res_id or 0,
|
||||
'state': state or 'draft',
|
||||
'requester_id': req_id,
|
||||
'requester_name': _user_name(cr, req_id),
|
||||
'current_stage_id': stage_id,
|
||||
'current_stage_sequence': stage_seq,
|
||||
'bypass_reason': bypass or '',
|
||||
'created_at': created.isoformat() if created else None,
|
||||
})
|
||||
|
||||
return _json_response({'items': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
_logger.exception('list requests failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/approval-requests/<int:req_id>/approve', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def approve_request(self, req_id, **kw):
|
||||
try:
|
||||
cr = _cr()
|
||||
body = _get_json_body()
|
||||
now = datetime.now()
|
||||
|
||||
cr.execute(
|
||||
"SELECT workflow_id, current_stage_id FROM encoach_approval_request WHERE id=%s",
|
||||
(req_id,))
|
||||
row = cr.fetchone()
|
||||
if not row:
|
||||
return _error_response('Request not found', 404)
|
||||
wf_id, stage_id = row
|
||||
|
||||
if stage_id:
|
||||
cr.execute(
|
||||
"UPDATE encoach_approval_stage SET status='approved', comment=%s, acted_at=%s "
|
||||
"WHERE id=%s", (body.get('comment', ''), now, stage_id))
|
||||
|
||||
cr.execute(
|
||||
"SELECT id FROM encoach_approval_stage WHERE workflow_id=%s ORDER BY sequence ASC",
|
||||
(wf_id,))
|
||||
all_stages = [r[0] for r in cr.fetchall()]
|
||||
|
||||
try:
|
||||
idx = all_stages.index(stage_id)
|
||||
except ValueError:
|
||||
idx = -1
|
||||
|
||||
if idx + 1 < len(all_stages):
|
||||
cr.execute(
|
||||
"UPDATE encoach_approval_request SET current_stage_id=%s, state='in_progress' "
|
||||
"WHERE id=%s", (all_stages[idx + 1], req_id))
|
||||
else:
|
||||
cr.execute(
|
||||
"UPDATE encoach_approval_request SET state='approved' WHERE id=%s", (req_id,))
|
||||
|
||||
return _json_response({'success': True, 'id': req_id})
|
||||
except Exception as e:
|
||||
_logger.exception('approve request failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/approval-requests/<int:req_id>/reject', type='http',
|
||||
auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def reject_request(self, req_id, **kw):
|
||||
try:
|
||||
cr = _cr()
|
||||
body = _get_json_body()
|
||||
now = datetime.now()
|
||||
|
||||
cr.execute("SELECT current_stage_id FROM encoach_approval_request WHERE id=%s", (req_id,))
|
||||
row = cr.fetchone()
|
||||
if not row:
|
||||
return _error_response('Request not found', 404)
|
||||
|
||||
stage_id = row[0]
|
||||
if stage_id:
|
||||
cr.execute(
|
||||
"UPDATE encoach_approval_stage SET status='rejected', comment=%s, acted_at=%s "
|
||||
"WHERE id=%s", (body.get('comment', ''), now, stage_id))
|
||||
|
||||
cr.execute(
|
||||
"UPDATE encoach_approval_request SET state='rejected' WHERE id=%s", (req_id,))
|
||||
return _json_response({'success': True, 'id': req_id})
|
||||
except Exception as e:
|
||||
_logger.exception('reject request failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
# ── Users for assignee selection ──
|
||||
|
||||
@http.route('/api/approval-users', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_users(self, **kw):
|
||||
try:
|
||||
cr = _cr()
|
||||
cr.execute(
|
||||
"SELECT ru.id, rp.name, ru.login "
|
||||
"FROM res_users ru JOIN res_partner rp ON rp.id = ru.partner_id "
|
||||
"WHERE ru.active = true AND ru.id > 1 "
|
||||
"ORDER BY rp.name LIMIT 100")
|
||||
items = [{'id': r[0], 'name': r[1], 'login': r[2]} for r in cr.fetchall()]
|
||||
return _json_response({'items': items})
|
||||
except Exception as e:
|
||||
_logger.exception('list users failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
34
custom_addons/encoach_exam_template/controllers/entities.py
Normal file
34
custom_addons/encoach_exam_template/controllers/entities.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntityController(http.Controller):
|
||||
|
||||
@http.route('/api/entities', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_entities(self, **kw):
|
||||
try:
|
||||
cr = request.env.cr
|
||||
cr.execute(
|
||||
"SELECT id, name, code, type FROM encoach_entity ORDER BY name")
|
||||
items = []
|
||||
for eid, name, code, etype in cr.fetchall():
|
||||
items.append({
|
||||
'id': eid,
|
||||
'name': name,
|
||||
'code': code or '',
|
||||
'type': etype or '',
|
||||
})
|
||||
return _json_response({'items': items, 'total': len(items)})
|
||||
except Exception as e:
|
||||
_logger.exception('list entities failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
@@ -0,0 +1,312 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
validate_token, _json_response as _base_json_response, _error_response,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return request.make_json_response(data, status=status)
|
||||
|
||||
|
||||
def _require_jwt():
|
||||
"""Validate JWT and set the user context. Return user or error response."""
|
||||
user = validate_token()
|
||||
if not user:
|
||||
return None, _error_response("Authentication required", status=401)
|
||||
request.update_env(user=user.id)
|
||||
return user, None
|
||||
|
||||
|
||||
def _normalize_dt(val):
|
||||
"""Convert ISO datetime strings (with T) to Odoo-compatible format."""
|
||||
if not val or not isinstance(val, str):
|
||||
return val
|
||||
return val.replace('T', ' ').replace('Z', '').split('+')[0]
|
||||
|
||||
|
||||
def _schedule_to_dict(rec):
|
||||
return {
|
||||
'id': rec.id,
|
||||
'name': rec.name,
|
||||
'exam_id': rec.exam_id.id if rec.exam_id else None,
|
||||
'exam_title': rec.exam_id.title if rec.exam_id else '',
|
||||
'entity_id': rec.entity_id.id if rec.entity_id else None,
|
||||
'entity_name': rec.entity_id.name if rec.entity_id else '',
|
||||
'start_date': rec.start_date.isoformat() if rec.start_date else None,
|
||||
'end_date': rec.end_date.isoformat() if rec.end_date else None,
|
||||
'state': rec.state,
|
||||
'assign_mode': rec.assign_mode,
|
||||
'full_length': rec.full_length,
|
||||
'generate_different': rec.generate_different,
|
||||
'auto_release_results': rec.auto_release_results,
|
||||
'auto_start': rec.auto_start,
|
||||
'official_exam': rec.official_exam,
|
||||
'hide_assignee_details': rec.hide_assignee_details,
|
||||
'batch_ids': rec.batch_ids.ids,
|
||||
'batch_names': [b.name for b in rec.batch_ids],
|
||||
'student_ids': rec.student_ids.ids,
|
||||
'assignee_count': rec.assignee_count,
|
||||
'completed_count': rec.completed_count,
|
||||
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||
}
|
||||
|
||||
|
||||
class EncoachExamScheduleController(http.Controller):
|
||||
|
||||
@http.route('/api/exam-schedules', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
def list_schedules(self, **kw):
|
||||
try:
|
||||
user, err = _require_jwt()
|
||||
if err:
|
||||
return err
|
||||
Schedule = request.env['encoach.exam.schedule'].sudo()
|
||||
domain = []
|
||||
state_filter = kw.get('state')
|
||||
if state_filter:
|
||||
domain.append(('state', '=', state_filter))
|
||||
|
||||
limit = int(kw.get('limit', 50))
|
||||
offset = int(kw.get('offset', 0))
|
||||
total = Schedule.search_count(domain)
|
||||
records = Schedule.search(domain, limit=limit, offset=offset,
|
||||
order='start_date desc')
|
||||
return _json_response({
|
||||
'items': [_schedule_to_dict(r) for r in records],
|
||||
'total': total,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('exam-schedules list failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/exam-schedules', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
def create_schedule(self, **kw):
|
||||
try:
|
||||
user, err = _require_jwt()
|
||||
if err:
|
||||
return err
|
||||
body = json.loads(request.httprequest.data or '{}')
|
||||
name = body.get('name', '').strip()
|
||||
exam_id = body.get('exam_id')
|
||||
if not name or not exam_id:
|
||||
return _json_response({'error': 'name and exam_id are required'}, 400)
|
||||
|
||||
start_date = _normalize_dt(body.get('start_date'))
|
||||
end_date = _normalize_dt(body.get('end_date'))
|
||||
if not start_date or not end_date:
|
||||
return _json_response({'error': 'start_date and end_date are required'}, 400)
|
||||
|
||||
now = datetime.now()
|
||||
try:
|
||||
sd = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
sd = datetime.strptime(start_date[:19], '%Y-%m-%d %H:%M:%S')
|
||||
try:
|
||||
ed = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
ed = datetime.strptime(end_date[:19], '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if sd <= now and ed > now:
|
||||
initial_state = 'active'
|
||||
elif sd > now:
|
||||
initial_state = 'planned'
|
||||
else:
|
||||
initial_state = 'past'
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
'exam_id': int(exam_id),
|
||||
'entity_id': body.get('entity_id') and int(body['entity_id']) or False,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'state': initial_state,
|
||||
'assign_mode': body.get('assign_mode', 'batch'),
|
||||
'full_length': body.get('full_length', True),
|
||||
'generate_different': body.get('generate_different', False),
|
||||
'auto_release_results': body.get('auto_release_results', False),
|
||||
'auto_start': body.get('auto_start', False),
|
||||
'official_exam': body.get('official_exam', False),
|
||||
'hide_assignee_details': body.get('hide_assignee_details', False),
|
||||
}
|
||||
|
||||
batch_ids = body.get('batch_ids', [])
|
||||
if batch_ids:
|
||||
vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])]
|
||||
|
||||
student_ids = body.get('student_ids', [])
|
||||
if student_ids:
|
||||
vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])]
|
||||
|
||||
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
|
||||
|
||||
self._create_individual_assignments(rec)
|
||||
|
||||
return _json_response(_schedule_to_dict(rec), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('exam-schedule create failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
def _create_individual_assignments(self, schedule):
|
||||
"""Create individual assignment records for each targeted student."""
|
||||
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||
created_user_ids = set()
|
||||
|
||||
if schedule.student_ids:
|
||||
for user in schedule.student_ids:
|
||||
if user.id not in created_user_ids:
|
||||
Assignment.create({
|
||||
'exam_id': schedule.exam_id.id,
|
||||
'schedule_id': schedule.id,
|
||||
'student_id': user.id,
|
||||
'access_start': schedule.start_date,
|
||||
'access_end': schedule.end_date,
|
||||
'status': 'assigned',
|
||||
})
|
||||
created_user_ids.add(user.id)
|
||||
|
||||
for batch in schedule.batch_ids:
|
||||
try:
|
||||
students = request.env['op.student'].sudo().search([('batch_id', '=', batch.id)])
|
||||
for student in students:
|
||||
user = student.user_id if hasattr(student, 'user_id') else None
|
||||
if user and user.id not in created_user_ids:
|
||||
Assignment.create({
|
||||
'exam_id': schedule.exam_id.id,
|
||||
'schedule_id': schedule.id,
|
||||
'student_id': user.id,
|
||||
'batch_id': batch.id,
|
||||
'access_start': schedule.start_date,
|
||||
'access_end': schedule.end_date,
|
||||
'status': 'assigned',
|
||||
})
|
||||
created_user_ids.add(user.id)
|
||||
except Exception:
|
||||
_logger.warning('Batch student lookup failed for batch %s', batch.id)
|
||||
|
||||
if schedule.assign_mode == 'entity' and schedule.entity_id:
|
||||
entity_users = schedule.entity_id.user_ids
|
||||
for user in entity_users:
|
||||
if user.id not in created_user_ids:
|
||||
Assignment.create({
|
||||
'exam_id': schedule.exam_id.id,
|
||||
'schedule_id': schedule.id,
|
||||
'student_id': user.id,
|
||||
'access_start': schedule.start_date,
|
||||
'access_end': schedule.end_date,
|
||||
'status': 'assigned',
|
||||
})
|
||||
created_user_ids.add(user.id)
|
||||
|
||||
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
|
||||
methods=['PUT'], csrf=False)
|
||||
def update_schedule(self, schedule_id, **kw):
|
||||
try:
|
||||
user, err = _require_jwt()
|
||||
if err:
|
||||
return err
|
||||
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
|
||||
if not rec.exists():
|
||||
return _json_response({'error': 'Not found'}, 404)
|
||||
|
||||
body = json.loads(request.httprequest.data or '{}')
|
||||
vals = {}
|
||||
for f in ('name', 'assign_mode',
|
||||
'full_length', 'generate_different', 'auto_release_results',
|
||||
'auto_start', 'official_exam', 'hide_assignee_details'):
|
||||
if f in body:
|
||||
vals[f] = body[f]
|
||||
if 'start_date' in body:
|
||||
vals['start_date'] = _normalize_dt(body['start_date'])
|
||||
if 'end_date' in body:
|
||||
vals['end_date'] = _normalize_dt(body['end_date'])
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = body['entity_id'] and int(body['entity_id']) or False
|
||||
if 'batch_ids' in body:
|
||||
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
|
||||
if 'student_ids' in body:
|
||||
vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])]
|
||||
if 'state' in body:
|
||||
vals['state'] = body['state']
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response(_schedule_to_dict(rec))
|
||||
except Exception as e:
|
||||
_logger.exception('exam-schedule update failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
def delete_schedule(self, schedule_id, **kw):
|
||||
try:
|
||||
user, err = _require_jwt()
|
||||
if err:
|
||||
return err
|
||||
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
|
||||
if not rec.exists():
|
||||
return _json_response({'error': 'Not found'}, 404)
|
||||
rec.assignment_ids.unlink()
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
_logger.exception('exam-schedule delete failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/exam-schedules/<int:schedule_id>/archive', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
def archive_schedule(self, schedule_id, **kw):
|
||||
try:
|
||||
user, err = _require_jwt()
|
||||
if err:
|
||||
return err
|
||||
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
|
||||
if not rec.exists():
|
||||
return _json_response({'error': 'Not found'}, 404)
|
||||
rec.write({'state': 'archived'})
|
||||
return _json_response(_schedule_to_dict(rec))
|
||||
except Exception as e:
|
||||
_logger.exception('exam-schedule archive failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/student/my-exams', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
def student_my_exams(self, **kw):
|
||||
"""Return exam assignments for the current student user."""
|
||||
try:
|
||||
user, err = _require_jwt()
|
||||
if err:
|
||||
return err
|
||||
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||
assignments = Assignment.search([
|
||||
('student_id', '=', user.id),
|
||||
('status', 'in', ['assigned', 'started']),
|
||||
], order='access_start asc')
|
||||
|
||||
result = []
|
||||
for a in assignments:
|
||||
schedule = a.schedule_id
|
||||
state = schedule.state if schedule else 'active'
|
||||
result.append({
|
||||
'id': a.id,
|
||||
'exam_id': a.exam_id.id,
|
||||
'exam_title': a.exam_id.title if a.exam_id else '',
|
||||
'schedule_name': schedule.name if schedule else '',
|
||||
'start_date': a.access_start.isoformat() if a.access_start else None,
|
||||
'end_date': a.access_end.isoformat() if a.access_end else None,
|
||||
'status': a.status,
|
||||
'schedule_state': state,
|
||||
'auto_start': schedule.auto_start if schedule else False,
|
||||
'can_start': state == 'active' and a.status == 'assigned',
|
||||
})
|
||||
return _json_response({'items': result})
|
||||
except Exception as e:
|
||||
_logger.exception('student my-exams failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
316
custom_addons/encoach_exam_template/controllers/exam_session.py
Normal file
316
custom_addons/encoach_exam_template/controllers/exam_session.py
Normal file
@@ -0,0 +1,316 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _question_to_student_dict(q):
|
||||
return {
|
||||
'id': q.id,
|
||||
'skill': q.skill or '',
|
||||
'question_type': q.question_type or '',
|
||||
'stem': q.stem or '',
|
||||
'options': json.loads(q.options) if q.options else [],
|
||||
'marks': q.marks,
|
||||
'difficulty': q.difficulty or '',
|
||||
'source_type': q.source_type or '',
|
||||
'source_id': q.source_id or 0,
|
||||
}
|
||||
|
||||
|
||||
class ExamSessionController(http.Controller):
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_session(self, exam_id, **kw):
|
||||
try:
|
||||
Exam = request.env['encoach.exam.custom'].sudo()
|
||||
exam = Exam.browse(exam_id)
|
||||
if not exam.exists():
|
||||
return _error_response('Exam not found', 404)
|
||||
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam.id),
|
||||
('status', 'in', ['in_progress', 'scoring']),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
attempt = Attempt.create({
|
||||
'student_id': uid,
|
||||
'exam_id': exam.id,
|
||||
'status': 'in_progress',
|
||||
'entity_id': exam.entity_id.id if exam.entity_id else False,
|
||||
})
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
saved_answers = {}
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
saved_answers[ans.question_id.id] = ans.answer or ''
|
||||
|
||||
sections = []
|
||||
for sec in exam.section_ids.sorted('sequence'):
|
||||
questions = []
|
||||
for q in sec.question_ids:
|
||||
q_dict = _question_to_student_dict(q)
|
||||
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
||||
questions.append(q_dict)
|
||||
|
||||
sec_dict = {
|
||||
'id': sec.id,
|
||||
'title': sec.title,
|
||||
'skill': sec.skill or '',
|
||||
'difficulty': sec.difficulty or '',
|
||||
'time_limit_min': sec.time_limit_min or 0,
|
||||
'total_marks': sec.total_marks or 0,
|
||||
'scoring_method': sec.scoring_method or 'auto',
|
||||
'sequence': sec.sequence,
|
||||
'questions': questions,
|
||||
}
|
||||
if sec.passage_text:
|
||||
sec_dict['passage_text'] = sec.passage_text
|
||||
if sec.instructions_text:
|
||||
sec_dict['instructions_text'] = sec.instructions_text
|
||||
if sec.content_json:
|
||||
try:
|
||||
sec_dict['content'] = json.loads(sec.content_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
sections.append(sec_dict)
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': exam.id,
|
||||
'exam_title': exam.title,
|
||||
'exam_mode': exam.exam_mode or 'official',
|
||||
'total_time_min': exam.total_time_min or 0,
|
||||
'total_marks': exam.total_marks or 0,
|
||||
'grading_system': exam.grading_system or 'ielts',
|
||||
'access_type': exam.access_type or 'private',
|
||||
'randomize_questions': exam.randomize_questions or False,
|
||||
'status': attempt.status,
|
||||
'started_at': str(attempt.started_at) if attempt.started_at else None,
|
||||
'sections': sections,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_session failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def autosave(self, exam_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
raw_answers = body.get('answers', [])
|
||||
|
||||
if not attempt_id:
|
||||
return _error_response('attempt_id is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||
return _error_response('Invalid attempt', 403)
|
||||
|
||||
answer_pairs = self._normalize_answers(raw_answers)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
saved = 0
|
||||
for q_id, answer_val in answer_pairs:
|
||||
existing = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'answer': str(answer_val)})
|
||||
else:
|
||||
Answer.create({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': q_id,
|
||||
'answer': str(answer_val),
|
||||
})
|
||||
saved += 1
|
||||
|
||||
return _json_response({'saved': saved})
|
||||
except Exception as e:
|
||||
_logger.exception('autosave failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def submit_exam(self, exam_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
attempt_id = body.get('attempt_id')
|
||||
raw_answers = body.get('answers', [])
|
||||
|
||||
if not attempt_id:
|
||||
return _error_response('attempt_id is required', 400)
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.browse(int(attempt_id))
|
||||
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||
return _error_response('Invalid attempt', 403)
|
||||
|
||||
answer_pairs = self._normalize_answers(raw_answers)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
Question = request.env['encoach.question'].sudo()
|
||||
|
||||
total_score = 0.0
|
||||
max_score = 0.0
|
||||
|
||||
for q_id, answer_val in answer_pairs:
|
||||
q = Question.browse(q_id)
|
||||
if not q.exists():
|
||||
continue
|
||||
|
||||
is_correct = False
|
||||
score = 0.0
|
||||
correct = (q.correct_answer or '').strip().lower()
|
||||
given = str(answer_val).strip().lower()
|
||||
|
||||
if correct and given:
|
||||
is_correct = correct == given
|
||||
score = q.marks if is_correct else 0.0
|
||||
|
||||
existing = Answer.search([
|
||||
('attempt_id', '=', attempt.id),
|
||||
('question_id', '=', q_id),
|
||||
], limit=1)
|
||||
vals = {
|
||||
'answer': str(answer_val),
|
||||
'score': score,
|
||||
'is_correct': is_correct,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
vals.update({
|
||||
'attempt_id': attempt.id,
|
||||
'question_id': q_id,
|
||||
})
|
||||
Answer.create(vals)
|
||||
|
||||
total_score += score
|
||||
max_score += q.marks
|
||||
|
||||
from odoo.fields import Datetime
|
||||
attempt.write({
|
||||
'status': 'completed',
|
||||
'finished_at': Datetime.now(),
|
||||
'total_score': total_score,
|
||||
'max_score': max_score,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'status': 'completed',
|
||||
'total_score': total_score,
|
||||
'max_score': max_score,
|
||||
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
|
||||
'results_available': True,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('submit_exam failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_status(self, exam_id, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam_id),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
return _error_response('No attempt found', 404)
|
||||
|
||||
scores_available = attempt.status == 'completed' and attempt.max_score > 0
|
||||
return _json_response({
|
||||
'status': attempt.status,
|
||||
'scores_available': scores_available,
|
||||
'total_score': attempt.total_score,
|
||||
'max_score': attempt.max_score,
|
||||
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_status failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get_results(self, exam_id, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('exam_id', '=', exam_id),
|
||||
('status', '=', 'completed'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not attempt:
|
||||
return _error_response('No completed attempt found', 404)
|
||||
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
answers = []
|
||||
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||
answers.append({
|
||||
'question_id': ans.question_id.id,
|
||||
'answer': ans.answer or '',
|
||||
'score': ans.score,
|
||||
'is_correct': ans.is_correct,
|
||||
'feedback': ans.feedback or '',
|
||||
})
|
||||
|
||||
Exam = request.env['encoach.exam.custom'].sudo()
|
||||
exam = Exam.browse(exam_id)
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': exam_id,
|
||||
'exam_title': exam.title if exam.exists() else '',
|
||||
'status': attempt.status,
|
||||
'total_score': attempt.total_score,
|
||||
'max_score': attempt.max_score,
|
||||
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
|
||||
'started_at': str(attempt.started_at) if attempt.started_at else None,
|
||||
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
|
||||
'answers': answers,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('get_results failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_answers(raw):
|
||||
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
|
||||
if isinstance(raw, list):
|
||||
pairs = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
qid = item.get('question_id') or item.get('qid')
|
||||
ans = item.get('answer', '')
|
||||
if qid:
|
||||
pairs.append((int(qid), ans))
|
||||
return pairs
|
||||
if isinstance(raw, dict):
|
||||
return [(int(k), v) for k, v in raw.items()]
|
||||
return []
|
||||
@@ -3,24 +3,17 @@ import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_body():
|
||||
try:
|
||||
return json.loads(request.httprequest.data or '{}')
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return request.make_json_response(data, status=status)
|
||||
|
||||
|
||||
class ExamStructureController(http.Controller):
|
||||
|
||||
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
|
||||
@http.route('/api/exam-structures', type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_structures(self, **kw):
|
||||
domain = [('active', '=', True)]
|
||||
entity_id = kw.get('entity_id')
|
||||
@@ -29,8 +22,9 @@ class ExamStructureController(http.Controller):
|
||||
|
||||
limit = int(kw.get('limit', 50))
|
||||
offset = int(kw.get('offset', 0))
|
||||
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
|
||||
total = request.env['encoach.exam.structure'].search_count(domain)
|
||||
records = request.env['encoach.exam.structure'].sudo().search(
|
||||
domain, limit=limit, offset=offset, order='create_date desc')
|
||||
total = request.env['encoach.exam.structure'].sudo().search_count(domain)
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
@@ -52,12 +46,13 @@ class ExamStructureController(http.Controller):
|
||||
|
||||
return _json_response({'items': items, 'total': total})
|
||||
|
||||
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
|
||||
@http.route('/api/exam-structures', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_structure(self, **kw):
|
||||
body = _json_body()
|
||||
body = _get_json_body()
|
||||
name = body.get('name')
|
||||
if not name:
|
||||
return _json_response({'error': 'name is required'}, status=400)
|
||||
return _error_response('name is required', 400)
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
@@ -69,19 +64,61 @@ class ExamStructureController(http.Controller):
|
||||
if entity_id:
|
||||
vals['entity_id'] = int(entity_id)
|
||||
|
||||
record = request.env['encoach.exam.structure'].create(vals)
|
||||
record = request.env['encoach.exam.structure'].sudo().create(vals)
|
||||
return _json_response({
|
||||
'id': record.id,
|
||||
'name': record.name,
|
||||
'entity_id': record.entity_id.id if record.entity_id else None,
|
||||
'industry': record.industry or '',
|
||||
'modules': json.loads(record.modules) if record.modules else [],
|
||||
'config': json.loads(record.config) if record.config else {},
|
||||
})
|
||||
|
||||
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
|
||||
def delete_structure(self, structure_id, **kw):
|
||||
record = request.env['encoach.exam.structure'].browse(structure_id)
|
||||
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_structure(self, structure_id, **kw):
|
||||
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
|
||||
if not record.exists():
|
||||
return _json_response({'error': 'Structure not found'}, status=404)
|
||||
return _error_response('Structure not found', 404)
|
||||
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = body['name']
|
||||
if 'industry' in body:
|
||||
vals['industry'] = body['industry']
|
||||
if 'modules' in body:
|
||||
vals['modules'] = json.dumps(body['modules'])
|
||||
if 'config' in body:
|
||||
vals['config'] = json.dumps(body['config'])
|
||||
if 'entity_id' in body:
|
||||
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
|
||||
|
||||
if vals:
|
||||
record.write(vals)
|
||||
|
||||
modules = []
|
||||
if record.modules:
|
||||
try:
|
||||
modules = json.loads(record.modules)
|
||||
except Exception:
|
||||
modules = []
|
||||
|
||||
return _json_response({
|
||||
'id': record.id,
|
||||
'name': record.name,
|
||||
'entity_id': record.entity_id.id if record.entity_id else None,
|
||||
'entity_name': record.entity_id.name if record.entity_id else None,
|
||||
'industry': record.industry or '',
|
||||
'modules': modules,
|
||||
'config': json.loads(record.config) if record.config else {},
|
||||
})
|
||||
|
||||
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_structure(self, structure_id, **kw):
|
||||
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
|
||||
if not record.exists():
|
||||
return _error_response('Structure not found', 404)
|
||||
record.unlink()
|
||||
return _json_response({'success': True})
|
||||
|
||||
@@ -3,14 +3,13 @@ import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return request.make_json_response(data, status=status)
|
||||
|
||||
|
||||
def _rubric_to_dict(rec):
|
||||
criteria_text = rec.criteria or ''
|
||||
criteria_count = 0
|
||||
@@ -26,6 +25,15 @@ def _rubric_to_dict(rec):
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
criteria_count = len([l for l in criteria_text.split('\n') if l.strip()])
|
||||
|
||||
levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||||
if rec.levels:
|
||||
try:
|
||||
parsed_levels = json.loads(rec.levels)
|
||||
if isinstance(parsed_levels, list) and parsed_levels:
|
||||
levels = parsed_levels
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return {
|
||||
'id': rec.id,
|
||||
'name': rec.name,
|
||||
@@ -33,15 +41,16 @@ def _rubric_to_dict(rec):
|
||||
'exam_type': rec.exam_type or '',
|
||||
'criteria': criteria_count or 1,
|
||||
'criteria_text': criteria_text,
|
||||
'levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'],
|
||||
'levels': levels,
|
||||
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||
}
|
||||
|
||||
|
||||
class EncoachRubricController(http.Controller):
|
||||
|
||||
@http.route('/api/rubrics', type='http', auth='user',
|
||||
@http.route('/api/rubrics', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_rubrics(self, **kw):
|
||||
try:
|
||||
Rubric = request.env['encoach.rubric'].sudo()
|
||||
@@ -58,14 +67,15 @@ class EncoachRubricController(http.Controller):
|
||||
_logger.exception('rubrics list failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/rubrics', type='http', auth='user',
|
||||
@http.route('/api/rubrics', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_rubric(self, **kw):
|
||||
try:
|
||||
body = json.loads(request.httprequest.data or '{}')
|
||||
body = _get_json_body()
|
||||
name = body.get('name', '').strip()
|
||||
if not name:
|
||||
return _json_response({'error': 'name is required'}, 400)
|
||||
return _error_response('name is required', 400)
|
||||
|
||||
vals = {
|
||||
'name': name,
|
||||
@@ -73,8 +83,140 @@ class EncoachRubricController(http.Controller):
|
||||
'criteria': body.get('criteria', ''),
|
||||
'exam_type': body.get('exam_type', 'academic'),
|
||||
}
|
||||
rec = Rubric = request.env['encoach.rubric'].sudo().create(vals)
|
||||
if 'levels' in body:
|
||||
vals['levels'] = json.dumps(body['levels'])
|
||||
rec = request.env['encoach.rubric'].sudo().create(vals)
|
||||
return _json_response(_rubric_to_dict(rec), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('rubric create failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
|
||||
methods=['PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_rubric(self, rubric_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Rubric not found', 404)
|
||||
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = body['name']
|
||||
if 'skill' in body:
|
||||
vals['skill'] = body['skill']
|
||||
if 'criteria' in body:
|
||||
vals['criteria'] = body['criteria']
|
||||
if 'exam_type' in body:
|
||||
vals['exam_type'] = body['exam_type']
|
||||
if 'levels' in body:
|
||||
vals['levels'] = json.dumps(body['levels'])
|
||||
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
|
||||
return _json_response(_rubric_to_dict(rec))
|
||||
except Exception as e:
|
||||
_logger.exception('rubric update failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_rubric(self, rubric_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Rubric not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
_logger.exception('rubric delete failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
|
||||
def _group_to_dict(rec):
|
||||
return {
|
||||
'id': rec.id,
|
||||
'name': rec.name,
|
||||
'rubric_ids': rec.rubric_ids.ids,
|
||||
'rubric_names': [r.name for r in rec.rubric_ids],
|
||||
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||
}
|
||||
|
||||
|
||||
class EncoachRubricGroupController(http.Controller):
|
||||
|
||||
@http.route('/api/rubric-groups', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def list_rubric_groups(self, **kw):
|
||||
try:
|
||||
Group = request.env['encoach.rubric.group'].sudo()
|
||||
limit = int(kw.get('limit', 50))
|
||||
offset = int(kw.get('offset', 0))
|
||||
records = Group.search([], limit=limit, offset=offset,
|
||||
order='create_date desc')
|
||||
total = Group.search_count([])
|
||||
return _json_response({
|
||||
'items': [_group_to_dict(r) for r in records],
|
||||
'total': total,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('rubric-groups list failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/rubric-groups', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create_rubric_group(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
name = body.get('name', '').strip()
|
||||
if not name:
|
||||
return _error_response('name is required', 400)
|
||||
rubric_ids = body.get('rubric_ids', [])
|
||||
rec = request.env['encoach.rubric.group'].sudo().create({
|
||||
'name': name,
|
||||
'rubric_ids': [(6, 0, rubric_ids)],
|
||||
})
|
||||
return _json_response(_group_to_dict(rec), 201)
|
||||
except Exception as e:
|
||||
_logger.exception('rubric-group create failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
|
||||
methods=['PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_rubric_group(self, group_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Group not found', 404)
|
||||
body = _get_json_body()
|
||||
vals = {}
|
||||
if 'name' in body:
|
||||
vals['name'] = body['name']
|
||||
if 'rubric_ids' in body:
|
||||
vals['rubric_ids'] = [(6, 0, body['rubric_ids'])]
|
||||
if vals:
|
||||
rec.write(vals)
|
||||
return _json_response(_group_to_dict(rec))
|
||||
except Exception as e:
|
||||
_logger.exception('rubric-group update failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
|
||||
methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def delete_rubric_group(self, group_id, **kw):
|
||||
try:
|
||||
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
|
||||
if not rec.exists():
|
||||
return _error_response('Group not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as e:
|
||||
_logger.exception('rubric-group delete failed')
|
||||
return _json_response({'error': str(e)}, 500)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_exam_schedule_lifecycle" model="ir.cron">
|
||||
<field name="name">Exam Schedule Lifecycle</field>
|
||||
<field name="model_id" ref="model_encoach_exam_schedule"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.update_lifecycle_states()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,4 +1,5 @@
|
||||
from . import rubric
|
||||
from . import rubric_group
|
||||
from . import exam_template
|
||||
from . import passage
|
||||
from . import audio_file
|
||||
@@ -8,4 +9,6 @@ from . import speaking_card
|
||||
from . import exam_custom
|
||||
from . import exam_custom_section
|
||||
from . import exam_assignment
|
||||
from . import exam_schedule
|
||||
from . import exam_structure
|
||||
from . import student_attempt
|
||||
|
||||
@@ -6,6 +6,7 @@ class EncoachExamAssignment(models.Model):
|
||||
_description = 'Exam Assignment'
|
||||
|
||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||
schedule_id = fields.Many2one('encoach.exam.schedule', ondelete='cascade')
|
||||
student_id = fields.Many2one('res.users', ondelete='cascade')
|
||||
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
||||
access_start = fields.Datetime()
|
||||
|
||||
@@ -6,13 +6,32 @@ class EncoachExamCustom(models.Model):
|
||||
_description = 'Custom Exam'
|
||||
|
||||
title = fields.Char(size=200, required=True)
|
||||
label = fields.Char(size=100)
|
||||
exam_mode = fields.Selection([
|
||||
('official', 'Official'),
|
||||
('practice', 'Practice'),
|
||||
], default='official')
|
||||
template_id = fields.Many2one('encoach.exam.template', ondelete='set null')
|
||||
structure_id = fields.Many2one('encoach.exam.structure', ondelete='set null')
|
||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
teacher_id = fields.Many2one('res.users', ondelete='set null')
|
||||
rubric_id = fields.Many2one('encoach.rubric', ondelete='set null')
|
||||
approval_workflow_id = fields.Integer()
|
||||
description = fields.Text()
|
||||
total_time_min = fields.Integer()
|
||||
total_marks = fields.Float()
|
||||
pass_threshold = fields.Float()
|
||||
grading_system = fields.Selection([
|
||||
('ielts', 'IELTS Band'),
|
||||
('percentage', 'Percentage'),
|
||||
('pass_fail', 'Pass / Fail'),
|
||||
('cefr', 'CEFR Level'),
|
||||
], default='ielts')
|
||||
access_type = fields.Selection([
|
||||
('private', 'Private'),
|
||||
('public', 'Public'),
|
||||
], default='private')
|
||||
results_release_mode = fields.Selection([
|
||||
('auto', 'Auto'),
|
||||
('manual_approval', 'Manual Approval'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
@@ -8,14 +9,19 @@ class EncoachExamCustomSection(models.Model):
|
||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||
title = fields.Char(size=200, required=True)
|
||||
skill = fields.Char(size=100)
|
||||
difficulty = fields.Char(size=50)
|
||||
question_count = fields.Integer()
|
||||
time_limit_min = fields.Integer()
|
||||
total_marks = fields.Float()
|
||||
scoring_method = fields.Selection([
|
||||
('auto', 'Auto'),
|
||||
('rubric', 'Rubric'),
|
||||
('mixed', 'Mixed'),
|
||||
], default='auto')
|
||||
sequence = fields.Integer(default=10)
|
||||
passage_text = fields.Text()
|
||||
instructions_text = fields.Text()
|
||||
content_json = fields.Text(help='JSON blob for tasks/parts/passages/sections config')
|
||||
question_ids = fields.Many2many(
|
||||
'encoach.question',
|
||||
'exam_custom_section_question_rel',
|
||||
|
||||
68
custom_addons/encoach_exam_template/models/exam_schedule.py
Normal file
68
custom_addons/encoach_exam_template/models/exam_schedule.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from odoo import models, fields, api
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class EncoachExamSchedule(models.Model):
|
||||
_name = 'encoach.exam.schedule'
|
||||
_description = 'Exam Schedule / Assignment Group'
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char(required=True, size=200)
|
||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
|
||||
start_date = fields.Datetime(required=True)
|
||||
end_date = fields.Datetime(required=True)
|
||||
|
||||
full_length = fields.Boolean(default=True)
|
||||
generate_different = fields.Boolean(default=False)
|
||||
auto_release_results = fields.Boolean(default=False)
|
||||
auto_start = fields.Boolean(default=False)
|
||||
official_exam = fields.Boolean(default=False)
|
||||
hide_assignee_details = fields.Boolean(default=False)
|
||||
|
||||
assign_mode = fields.Selection([
|
||||
('entity', 'Entire Entity'),
|
||||
('batch', 'Class / Batch'),
|
||||
('individual', 'Individual Students'),
|
||||
], default='batch', required=True)
|
||||
|
||||
batch_ids = fields.Many2many('op.batch', string='Classes')
|
||||
student_ids = fields.Many2many('res.users', 'exam_schedule_student_rel',
|
||||
'schedule_id', 'user_id', string='Students')
|
||||
|
||||
state = fields.Selection([
|
||||
('planned', 'Planned'),
|
||||
('active', 'Active'),
|
||||
('past', 'Past'),
|
||||
('start_expired', 'Start Expired'),
|
||||
('archived', 'Archived'),
|
||||
], default='planned', required=True, index=True)
|
||||
|
||||
assignment_ids = fields.One2many('encoach.exam.assignment', 'schedule_id')
|
||||
assignee_count = fields.Integer(compute='_compute_counts', store=True)
|
||||
completed_count = fields.Integer(compute='_compute_counts', store=True)
|
||||
|
||||
@api.depends('assignment_ids', 'assignment_ids.status')
|
||||
def _compute_counts(self):
|
||||
for rec in self:
|
||||
assignments = rec.assignment_ids
|
||||
rec.assignee_count = len(assignments)
|
||||
rec.completed_count = len(assignments.filtered(lambda a: a.status == 'completed'))
|
||||
|
||||
def update_lifecycle_states(self):
|
||||
"""Cron job: transition schedules based on current time."""
|
||||
now = datetime.now()
|
||||
|
||||
planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '>', now)])
|
||||
planned.write({'state': 'active'})
|
||||
|
||||
expired_planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '<=', now)])
|
||||
expired_planned.write({'state': 'start_expired'})
|
||||
|
||||
active_ended = self.search([('state', '=', 'active'), ('end_date', '<=', now)])
|
||||
active_ended.write({'state': 'past'})
|
||||
for rec in active_ended:
|
||||
rec.assignment_ids.filtered(
|
||||
lambda a: a.status in ('assigned', 'started')
|
||||
).write({'status': 'expired'})
|
||||
@@ -11,6 +11,7 @@ class EncoachRubric(models.Model):
|
||||
('speaking', 'Speaking'),
|
||||
], required=True)
|
||||
criteria = fields.Text(required=True)
|
||||
levels = fields.Text(help='JSON list of CEFR levels, e.g. ["A1","A2","B1","B2","C1","C2"]')
|
||||
exam_type = fields.Selection([
|
||||
('academic', 'Academic'),
|
||||
('general_training', 'General Training'),
|
||||
|
||||
15
custom_addons/encoach_exam_template/models/rubric_group.py
Normal file
15
custom_addons/encoach_exam_template/models/rubric_group.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachRubricGroup(models.Model):
|
||||
_name = 'encoach.rubric.group'
|
||||
_description = 'Rubric Group'
|
||||
|
||||
name = fields.Char(size=200, required=True)
|
||||
rubric_ids = fields.Many2many(
|
||||
'encoach.rubric',
|
||||
'encoach_rubric_group_rel',
|
||||
'group_id',
|
||||
'rubric_id',
|
||||
string='Rubrics',
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachStudentAttempt(models.Model):
|
||||
_name = 'encoach.student.attempt'
|
||||
_description = 'Student Exam Attempt'
|
||||
_order = 'id desc'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
status = fields.Selection([
|
||||
('in_progress', 'In Progress'),
|
||||
('scoring', 'Scoring'),
|
||||
('completed', 'Completed'),
|
||||
('abandoned', 'Abandoned'),
|
||||
], default='in_progress', required=True)
|
||||
started_at = fields.Datetime(default=fields.Datetime.now)
|
||||
finished_at = fields.Datetime()
|
||||
overall_band = fields.Float()
|
||||
cefr_level = fields.Char(size=10)
|
||||
listening_band = fields.Float()
|
||||
reading_band = fields.Float()
|
||||
writing_band = fields.Float()
|
||||
speaking_band = fields.Float()
|
||||
total_score = fields.Float()
|
||||
max_score = fields.Float()
|
||||
|
||||
|
||||
class EncoachStudentAnswer(models.Model):
|
||||
_name = 'encoach.student.answer'
|
||||
_description = 'Student Answer'
|
||||
|
||||
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
|
||||
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
|
||||
answer = fields.Text()
|
||||
score = fields.Float()
|
||||
is_correct = fields.Boolean()
|
||||
feedback = fields.Text()
|
||||
|
||||
|
||||
class EncoachStudentScore(models.Model):
|
||||
_name = 'encoach.student.score'
|
||||
_description = 'Student Skill Score'
|
||||
|
||||
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
|
||||
skill = fields.Char(size=50, required=True)
|
||||
band_score = fields.Float()
|
||||
raw_score = fields.Float()
|
||||
max_score = fields.Float()
|
||||
cefr_level = fields.Char(size=10)
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
@@ -6,7 +6,12 @@ access_encoach_question_user,encoach.question.user,model_encoach_question,base.g
|
||||
access_encoach_writing_prompt_user,encoach.writing.prompt.user,model_encoach_writing_prompt,base.group_user,1,1,1,1
|
||||
access_encoach_speaking_card_user,encoach.speaking.card.user,model_encoach_speaking_card,base.group_user,1,1,1,1
|
||||
access_encoach_rubric_user,encoach.rubric.user,model_encoach_rubric,base.group_user,1,1,1,1
|
||||
access_encoach_rubric_group_user,encoach.rubric.group.user,model_encoach_rubric_group,base.group_user,1,1,1,1
|
||||
access_encoach_exam_custom_user,encoach.exam.custom.user,model_encoach_exam_custom,base.group_user,1,1,1,1
|
||||
access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_encoach_exam_custom_section,base.group_user,1,1,1,1
|
||||
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
|
||||
access_encoach_exam_schedule_user,encoach.exam.schedule.user,model_encoach_exam_schedule,base.group_user,1,1,1,1
|
||||
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
|
||||
access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
|
||||
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
|
||||
access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -186,21 +186,38 @@ class EncoachExamSessionController(http.Controller):
|
||||
q_dict = _question_to_student_dict(q)
|
||||
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
||||
questions.append(q_dict)
|
||||
sections.append({
|
||||
sec_dict = {
|
||||
'id': sec.id,
|
||||
'title': sec.title,
|
||||
'skill': sec.skill or '',
|
||||
'difficulty': sec.difficulty or '',
|
||||
'time_limit_min': sec.time_limit_min or 0,
|
||||
'total_marks': sec.total_marks or 0,
|
||||
'scoring_method': sec.scoring_method or 'auto',
|
||||
'sequence': sec.sequence,
|
||||
'questions': questions,
|
||||
})
|
||||
}
|
||||
if sec.passage_text:
|
||||
sec_dict['passage_text'] = sec.passage_text
|
||||
if sec.instructions_text:
|
||||
sec_dict['instructions_text'] = sec.instructions_text
|
||||
if sec.content_json:
|
||||
try:
|
||||
sec_dict['content'] = json.loads(sec.content_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
sections.append(sec_dict)
|
||||
|
||||
return _json_response({
|
||||
'attempt_id': attempt.id,
|
||||
'exam_id': exam.id,
|
||||
'exam_title': exam.title,
|
||||
'exam_mode': exam.exam_mode or 'official',
|
||||
'total_time_min': exam.total_time_min or 0,
|
||||
'total_marks': exam.total_marks or 0,
|
||||
'grading_system': exam.grading_system or 'ielts',
|
||||
'access_type': exam.access_type or 'private',
|
||||
'randomize_questions': exam.randomize_questions or False,
|
||||
'status': attempt.status,
|
||||
'started_at': attempt.started_at,
|
||||
'sections': sections,
|
||||
|
||||
Reference in New Issue
Block a user