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:
Yamen Ahmad
2026-04-16 16:53:09 +04:00
parent 01cce7662d
commit 74d83af57f
24 changed files with 2020 additions and 69 deletions

View File

@@ -314,38 +314,194 @@ 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"):
return self._generate_passage(ai, body)
if has_ai:
return self._generate_passage(ai, body)
return _json_response(self._fallback_passage(body))
if body.get("generate_instructions"):
return self._generate_writing_instructions(ai, body)
if has_ai:
return self._generate_writing_instructions(ai, body)
return _json_response(self._fallback_writing_instructions(body))
if body.get("generate_script"):
return self._generate_speaking_script(ai, body)
if has_ai:
return self._generate_speaking_script(ai, body)
return _json_response(self._fallback_speaking_script(body))
if body.get("generate_context"):
return self._generate_listening_context(ai, body)
if has_ai:
return self._generate_listening_context(ai, body)
return _json_response(self._fallback_listening_context(body))
if body.get("generate_exercises"):
return self._generate_exercises(ai, module, body)
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
messages = [
{"role": "system", "content": (
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
f"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
)},
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
]
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
if has_ai:
messages = [
{"role": "system", "content": (
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
f"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
)},
{"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)
try:
Section = request.env["encoach.exam.custom.section"]
seq = 10
for mod_key, mod_data in modules.items():
Section.sudo().create({
"exam_id": exam.id,
"title": mod_key.capitalize(),
"skill": mod_key,
"time_limit_min": mod_data.get("timer", 0),
"scoring_method": "auto",
"sequence": seq,
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",
}
seq = 10
total_questions = 0
for mod_key, mod_data in modules.items():
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),
"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
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,
})
seq += 10
except KeyError:
pass
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

View File

@@ -1,2 +1,3 @@
from . import ai_settings
from . import ai_log
from . import constants

View 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',
]

View File

@@ -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

View File

@@ -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>

View File

@@ -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',

View File

@@ -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

View File

@@ -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)

View 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)

View File

@@ -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)

View 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 []

View File

@@ -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})

View File

@@ -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)

View File

@@ -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>

View File

@@ -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

View File

@@ -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()

View File

@@ -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'),

View File

@@ -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',

View 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'})

View File

@@ -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'),

View 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',
)

View File

@@ -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')

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_encoach_writing_prompt_user encoach.writing.prompt.user model_encoach_writing_prompt base.group_user 1 1 1 1
7 access_encoach_speaking_card_user encoach.speaking.card.user model_encoach_speaking_card base.group_user 1 1 1 1
8 access_encoach_rubric_user encoach.rubric.user model_encoach_rubric base.group_user 1 1 1 1
9 access_encoach_rubric_group_user encoach.rubric.group.user model_encoach_rubric_group base.group_user 1 1 1 1
10 access_encoach_exam_custom_user encoach.exam.custom.user model_encoach_exam_custom base.group_user 1 1 1 1
11 access_encoach_exam_custom_section_user encoach.exam.custom.section.user model_encoach_exam_custom_section base.group_user 1 1 1 1
12 access_encoach_exam_assignment_user encoach.exam.assignment.user model_encoach_exam_assignment base.group_user 1 1 1 1
13 access_encoach_exam_schedule_user encoach.exam.schedule.user model_encoach_exam_schedule base.group_user 1 1 1 1
14 access_encoach_exam_structure_user encoach.exam.structure.user model_encoach_exam_structure base.group_user 1 1 1 1
15 access_encoach_student_attempt_user encoach.student.attempt.user model_encoach_student_attempt base.group_user 1 1 1 1
16 access_encoach_student_answer_user encoach.student.answer.user model_encoach_student_answer base.group_user 1 1 1 1
17 access_encoach_student_score_user encoach.student.score.user model_encoach_student_score base.group_user 1 1 1 1

View File

@@ -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,