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 b9df9b5299
commit 50f58dc995
40 changed files with 5982 additions and 503 deletions

View File

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

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,

View File

@@ -1,4 +1,5 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
@@ -47,5 +48,10 @@ const navGroups: NavGroup[] = [
];
export default function StudentLayout() {
return <RoleLayout navGroups={navGroups} role="student" />;
return (
<>
<RoleLayout navGroups={navGroups} role="student" />
<ExamPopup />
</>
);
}

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import type { StudentExamAssignment } from "@/types";
export default function ExamPopup() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
const { data } = useQuery({
queryKey: ["student-my-exams"],
queryFn: () => assignmentsService.getStudentExams(),
refetchInterval: 30000,
});
const exams = (data?.items ?? []) as StudentExamAssignment[];
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
useEffect(() => {
if (pendingExams.length > 0 && !open) {
setOpen(true);
}
}, [pendingExams.length]);
if (pendingExams.length === 0) return null;
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
};
const handleStart = (exam: StudentExamAssignment) => {
setOpen(false);
navigate(`/student/exam/${exam.exam_id}/session`);
};
const handleDismiss = (id: number) => {
setDismissed((prev) => new Set([...prev, id]));
const remaining = pendingExams.filter((e) => e.id !== id);
if (remaining.length === 0) setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" />
Upcoming Exams ({pendingExams.length})
</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{pendingExams.map((exam) => (
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
<Badge
variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs"
>
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
</Badge>
</div>
{exam.schedule_name && exam.exam_title && (
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
From: {formatDate(exam.start_date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
To: {formatDate(exam.end_date)}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!exam.can_start}
onClick={() => handleStart(exam)}
className="gap-1.5"
>
<PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? "Start Exam" : "Not Available Yet"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(exam.id)}
>
Dismiss
</Button>
{!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ml-auto">
{exam.schedule_state === "planned"
? "Exam will be available once it becomes active"
: "Exam is not currently active"}
</span>
)}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,119 +1,556 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
CheckCircle,
XCircle,
Clock,
Loader2,
Trash2,
ChevronRight,
ShieldCheck,
FileText,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api-client";
const workflows = [
{
id: 1, name: "Exam Content Review", status: "In Progress",
steps: [
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
],
},
{
id: 2, name: "Rubric Approval", status: "Completed",
steps: [
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
],
},
{
id: 3, name: "New Exam Structure", status: "Rejected",
steps: [
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
],
},
];
interface WorkflowStep {
id: number;
sequence: number;
approver_id: number | null;
approver_name: string;
status: string;
comment: string;
auto_escalate: boolean;
max_days: number;
acted_at: string | null;
}
interface Workflow {
id: number;
name: string;
type: string;
entity_id: number | null;
entity_name: string | null;
allow_bypass: boolean;
active: boolean;
steps: WorkflowStep[];
created: string;
}
interface ApprovalRequest {
id: number;
workflow_id: number | null;
workflow_name: string;
res_model: string;
res_id: number;
state: string;
requester_id: number | null;
requester_name: string;
current_stage_id: number | null;
current_stage_sequence: number | null;
bypass_reason: string;
created_at: string | null;
}
interface UserItem {
id: number;
name: string;
login: string;
}
const statusIcon = (s: string) => {
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-warning" />;
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-amber-500" />;
};
const stateBadge = (state: string) => {
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
in_progress: "secondary",
rejected: "destructive",
draft: "outline",
};
return (
<Badge variant={map[state] || "outline"} className="capitalize">
{state.replace(/_/g, " ")}
</Badge>
);
};
export default function ApprovalWorkflowsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [formName, setFormName] = useState("");
const [formType, setFormType] = useState("custom");
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
{ approver_id: "" },
{ approver_id: "" },
]);
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
const [rejectComment, setRejectComment] = useState("");
const workflowsQ = useQuery({
queryKey: ["approval-workflows"],
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
});
const requestsQ = useQuery({
queryKey: ["approval-requests"],
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
});
const usersQ = useQuery({
queryKey: ["approval-users"],
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
});
const workflows = workflowsQ.data?.items ?? [];
const requests = requestsQ.data?.items ?? [];
const users = usersQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
setCreateOpen(false);
resetForm();
toast({ title: "Workflow created" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
toast({ title: "Workflow deleted" });
},
});
const approveMut = useMutation({
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-requests"] });
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
toast({ title: "Request approved" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
});
const rejectMut = useMutation({
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
api.post(`/approval-requests/${id}/reject`, { comment }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-requests"] });
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
setRejectDialog(null);
setRejectComment("");
toast({ title: "Request rejected" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
});
const resetForm = () => {
setFormName("");
setFormType("custom");
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
};
const handleCreate = () => {
createMut.mutate({
name: formName.trim(),
type: formType,
steps: formSteps
.filter((s) => s.approver_id)
.map((s) => ({ approver_id: Number(s.approver_id) })),
});
};
const pendingRequests = requests.filter((r) => r.state === "in_progress");
const completedRequests = requests.filter((r) => r.state !== "in_progress");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
<p className="text-muted-foreground">
Manage multi-step approval processes for exam content.
</p>
</div>
<Dialog>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetForm();
}}
>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" /> Create Workflow
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>Define a multi-step approval process.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
<div className="space-y-2">
<Label>Step 1 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
</Select>
<Label>
Workflow Name <span className="text-destructive">*</span>
</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Exam Content Review"
/>
</div>
<div className="space-y-2">
<Label>Step 2 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
<Label>Type</Label>
<Select value={formType} onValueChange={setFormType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="exam_publish">Exam Publication</SelectItem>
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
<SelectItem value="content_publish">Content Publication</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full">Create</Button>
{formSteps.map((step, i) => (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<Label>Step {i + 1} Approver</Label>
{formSteps.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setFormSteps((s) => s.filter((_, idx) => idx !== i))
}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<Select
value={step.approver_id}
onValueChange={(v) =>
setFormSteps((s) =>
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select approver" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
>
<Plus className="h-3 w-3 mr-1" /> Add Step
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={!formName.trim() || createMut.isPending}
onClick={handleCreate}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-4">
{/* Loading */}
{(workflowsQ.isLoading || requestsQ.isLoading) && (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Pending Requests */}
{pendingRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
Pending Approval ({pendingRequests.length})
</h2>
{pendingRequests.map((req) => {
const wf = workflows.find((w) => w.id === req.workflow_id);
return (
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-semibold">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
Requested by {req.requester_name} &middot;{" "}
{req.res_model.replace("encoach.", "")} #{req.res_id}
</p>
</div>
{stateBadge(req.state)}
</div>
{wf && (
<div className="flex items-center gap-2 flex-wrap mb-3">
{wf.steps.map((step, i) => {
const isActive = step.id === req.current_stage_id;
return (
<div key={step.id} className="flex items-center gap-2">
<div
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
isActive
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
: step.status === "approved"
? "border-green-200 bg-green-50/50"
: step.status === "rejected"
? "border-red-200 bg-red-50/50"
: "bg-muted/30"
}`}
>
{statusIcon(step.status)}
<div>
<p className="text-sm font-medium">Step {i + 1}</p>
<p className="text-xs text-muted-foreground">
{step.approver_name}
</p>
</div>
</div>
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
);
})}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
disabled={approveMut.isPending}
onClick={() => approveMut.mutate(req.id)}
>
{approveMut.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
)}
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectDialog(req.id)}
>
<XCircle className="h-3.5 w-3.5 mr-1" />
Reject
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Workflow Templates */}
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Workflow Templates ({workflows.length})
</h2>
{workflows.length === 0 && !workflowsQ.isLoading && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No workflows defined yet. Create one to get started.
</CardContent>
</Card>
)}
{workflows.map((wf) => (
<Card key={wf.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
<Badge variant="outline" className="text-xs capitalize">
{wf.type.replace(/_/g, " ")}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div key={step.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
{statusIcon(step.status)}
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{i + 1}
</div>
<div>
<p className="text-sm font-medium">{step.name}</p>
<p className="text-xs text-muted-foreground">{step.assignee}</p>
<p className="text-sm font-medium">{step.approver_name}</p>
<p className="text-xs text-muted-foreground">
{step.max_days}d limit
{step.auto_escalate ? " · auto-escalate" : ""}
</p>
</div>
</div>
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
))}
</div>
{wf.status === "In Progress" && (
<div className="space-y-4 mt-4">
<AiGradingAssistant onAccept={(marks, feedback) => {
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
}} />
<div className="flex gap-2">
<Button size="sm" variant="default">Approve</Button>
<Button size="sm" variant="outline">Reject</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Completed Requests */}
{completedRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
Recent Decisions ({completedRequests.length})
</h2>
{completedRequests.map((req) => (
<Card key={req.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
{req.requester_name} &middot; {req.res_model.replace("encoach.", "")} #
{req.res_id}
{req.created_at && (
<>
{" "}
&middot; {new Date(req.created_at).toLocaleDateString()}
</>
)}
</p>
</div>
{stateBadge(req.state)}
</CardContent>
</Card>
))}
</div>
)}
{/* Reject Dialog */}
<Dialog
open={rejectDialog !== null}
onOpenChange={(open) => {
if (!open) {
setRejectDialog(null);
setRejectComment("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Request</DialogTitle>
<DialogDescription>Provide a reason for rejection.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
placeholder="Reason for rejection..."
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={rejectMut.isPending}
onClick={() => {
if (rejectDialog !== null) {
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
}
}}
>
{rejectMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Reject
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,91 +1,320 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2 } from "lucide-react";
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
import {
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
} from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import { api } from "@/lib/api-client";
import { useBatches, useStudents } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
interface CustomExam {
id: number;
title: string;
status: string;
}
interface Entity {
id: number;
name: string;
}
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
];
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
{ key: "full_length", label: "Full length exams" },
{ key: "generate_different", label: "Generate different exams" },
{ key: "auto_release_results", label: "Auto release results" },
{ key: "auto_start", label: "Auto start exam" },
{ key: "official_exam", label: "Official Exam" },
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
];
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
switch (s) {
case "active": return "default";
case "planned": return "secondary";
case "past": return "outline";
case "start_expired": return "destructive";
case "archived": return "outline";
default: return "secondary";
}
};
type FormState = {
name: string;
exam_id: string;
entity_id: string;
start_date: string;
start_time: string;
end_date: string;
end_time: string;
assign_mode: "entity" | "batch" | "individual";
batch_ids: Set<number>;
student_ids: Set<number>;
full_length: boolean;
generate_different: boolean;
auto_release_results: boolean;
auto_start: boolean;
official_exam: boolean;
hide_assignee_details: boolean;
};
const emptyForm = (): FormState => ({
name: "",
exam_id: "",
entity_id: "",
start_date: "",
start_time: "09:00",
end_date: "",
end_time: "17:00",
assign_mode: "batch",
batch_ids: new Set(),
student_ids: new Set(),
full_length: true,
generate_different: false,
auto_release_results: false,
auto_start: false,
official_exam: false,
hide_assignee_details: false,
});
export default function AssignmentsPage() {
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
const [form, setForm] = useState<FormState>(emptyForm());
const { toast } = useToast();
const qc = useQueryClient();
const assignmentsQ = useAssignments({ size: 200 });
const createMut = useCreateAssignment();
const schedulesQ = useQuery({
queryKey: ["exam-schedules", stateFilter],
queryFn: () => assignmentsService.listSchedules(
stateFilter === "all" ? {} : { state: stateFilter }
),
});
const schedules = schedulesQ.data?.items ?? [];
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
const assignments = Array.isArray(items) ? items : [];
const customExamsQ = useQuery({
queryKey: ["custom-exams-for-assign"],
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
});
const publishedExams = (customExamsQ.data?.items ?? []).filter(
(e) => e.status === "published" || e.status === "draft"
);
const entitiesQ = useQuery({
queryKey: ["entities-for-assign"],
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const batchesQ = useBatches({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const studentsQ = useStudents({ size: 200 });
const students = studentsQ.data?.items ?? [];
const stateCounts = useMemo(() => {
const all = schedulesQ.data?.items ?? [];
const counts: Record<string, number> = { all: all.length };
for (const s of all) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [schedulesQ.data]);
const allSchedulesQ = useQuery({
queryKey: ["exam-schedules", "all"],
queryFn: () => assignmentsService.listSchedules({}),
enabled: stateFilter !== "all",
});
const totalCounts = useMemo(() => {
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
const counts: Record<string, number> = { all: items.length };
for (const s of items) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [stateFilter, schedules, allSchedulesQ.data]);
const createMut = useMutation({
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
setCreateOpen(false);
setForm(emptyForm());
toast({ title: "Exam scheduled successfully" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const archiveMut = useMutation({
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule archived" });
},
});
const q = search.toLowerCase();
const filtered = assignments.filter(a =>
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
const filtered = schedules.filter(
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
);
const loading = assignmentsQ.isLoading;
function handleCreate() {
createMut.mutate(
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
{
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
if (!form.name || !form.exam_id || !startDt || !endDt) {
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
return;
}
createMut.mutate({
name: form.name,
exam_id: Number(form.exam_id),
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
start_date: startDt,
end_date: endDt,
assign_mode: form.assign_mode,
batch_ids: [...form.batch_ids],
student_ids: [...form.student_ids],
full_length: form.full_length,
generate_different: form.generate_different,
auto_release_results: form.auto_release_results,
auto_start: form.auto_start,
official_exam: form.official_exam,
hide_assignee_details: form.hide_assignee_details,
});
}
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<p className="text-muted-foreground">Create and manage assignments.</p>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" /> Assignments
</h1>
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Assignment
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
</Button>
</div>
{/* State tabs */}
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
{STATE_TABS.map((tab) => (
<button
key={tab.key}
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
stateFilter === tab.key
? "bg-white text-primary shadow-sm"
: "text-primary/70 hover:text-primary"
}`}
onClick={() => setStateFilter(tab.key)}
>
{tab.icon}
{tab.label}
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
({totalCounts[tab.key] ?? 0})
</span>
</button>
))}
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
{schedulesQ.isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : filtered.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center text-muted-foreground">
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="font-medium">No exam schedules found</p>
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Schedule Name</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
<TableHead>Assignees</TableHead>
<TableHead>Completed</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
{filtered.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.exam_title}</TableCell>
<TableCell>{s.entity_name || "—"}</TableCell>
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
</TableCell>
<TableCell>{s.completed_count}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{s.state !== "archived" && (
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
onClick={() => archiveMut.mutate(s.id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
)}
{filtered.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>{a.entity_name || "—"}</TableCell>
<TableCell>{a.start_date || "—"}</TableCell>
<TableCell>{a.end_date || "—"}</TableCell>
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
<TableCell>{a.assignee_count ?? 0}</TableCell>
<TableCell>{a.completed_count ?? 0}</TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -94,21 +323,169 @@ export default function AssignmentsPage() {
</Card>
)}
{/* Create Schedule Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Schedule Exam</DialogTitle>
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Schedule Name <span className="text-destructive">*</span></Label>
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
</div>
<div className="space-y-1">
<Label>Select Exam <span className="text-destructive">*</span></Label>
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
<SelectContent>
{publishedExams.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
</SelectItem>
))}
{publishedExams.length === 0 && (
<SelectItem value="_none" disabled>No exams available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Date/Time */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1">
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Start Time</Label>
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Time</Label>
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
</div>
</div>
</div>
{/* Options checkboxes */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Exam Options</Label>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{OPTION_FIELDS.map((opt) => (
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={form[opt.key] as boolean}
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
{/* Assignment target */}
<div className="space-y-3">
<Label className="text-sm font-semibold">Assign To</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs">Assignment Mode</Label>
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="entity">Entire Entity</SelectItem>
<SelectItem value="batch">Class / Batch</SelectItem>
<SelectItem value="individual">Individual Students</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Entity</Label>
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
<SelectContent>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
{entities.length === 0 && (
<SelectItem value="_none" disabled>No entities</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Batch selection */}
{form.assign_mode === "batch" && (
<div className="space-y-1">
<Label className="text-xs">Select Classes</Label>
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
{batches.map((b) => (
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.batch_ids.has(b.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.batch_ids);
checked ? next.add(b.id) : next.delete(b.id);
return { ...p, batch_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{b.name}
</label>
))}
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
</div>
</div>
)}
{/* Individual student selection */}
{form.assign_mode === "individual" && (
<div className="space-y-1">
<Label className="text-xs">Select Students</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{students.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.student_ids.has(s.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.student_ids);
checked ? next.add(s.id) : next.delete(s.id);
return { ...p, student_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{s.name}
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
</label>
))}
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
{createMut.isPending ? "Creating..." : "Create"}
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
</Button>
</DialogFooter>
</DialogContent>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -52,12 +53,17 @@ import {
Sparkles,
Settings2,
ListChecks,
GraduationCap,
Dumbbell,
Lock,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { generationService } from "@/services/generation.service";
import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import type { ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -222,6 +228,7 @@ interface ModuleState {
approvalWorkflow: string;
rubricGroup: string;
rubricCriteria: string;
rubricId: string;
totalMarks: number;
gradingSystem: string;
shuffling: boolean;
@@ -273,6 +280,7 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
approvalWorkflow: "",
rubricGroup: "",
rubricCriteria: "",
rubricId: "",
totalMarks: 0,
gradingSystem: "",
shuffling: false,
@@ -288,9 +296,17 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
export default function GenerationPage() {
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [examMode, setExamMode] = useState<"official" | "practice">("official");
const [title, setTitle] = useState("");
const [examLabel, setExamLabel] = useState("");
const [examStructure, setExamStructure] = useState("");
const [newStructureDialog, setNewStructureDialog] = useState<{
open: boolean;
name: string;
modules: Set<ModuleKey>;
}>({ open: false, name: "", modules: new Set() });
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
@@ -352,6 +368,47 @@ export default function GenerationPage() {
});
const structures = structuresQ.data?.items ?? [];
const rubricsQ = useQuery({
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const rubrics = rubricsQ.data?.items ?? [];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
queryFn: () => examsService.listRubricGroups({}),
});
const rubricGroups = rubricGroupsQ.data?.items ?? [];
const entitiesQ = useQuery({
queryKey: ["entities"],
queryFn: () => api.get<{ items: { id: number; name: string; code: string; type: string }[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const workflowsQ = useQuery({
queryKey: ["approval-workflows"],
queryFn: () => api.get<{ items: { id: number; name: string; type: string }[] }>("/approval-workflows"),
});
const approvalWorkflows = workflowsQ.data?.items ?? [];
const selectedStructure = structures.find((s) => String(s.id) === examStructure);
const selectedStructureConfig = (selectedStructure?.config && typeof selectedStructure.config === "object" ? selectedStructure.config : null) as ExamStructureConfig | null;
const isOfficialLocked = examMode === "official" && !!selectedStructure;
const createStructureMut = useMutation({
mutationFn: (data: { name: string; modules: string[] }) =>
examsService.createStructure(data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setExamStructure(String(created.id));
setNewStructureDialog({ open: false, name: "", modules: new Set() });
toast({ title: "Structure created", description: `"${created.name}" has been added.` });
},
onError: (err: Error) =>
toast({ variant: "destructive", title: "Failed to create structure", description: err.message }),
});
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
return moduleStates[mod] ?? defaultModuleState(mod);
}, [moduleStates]);
@@ -579,13 +636,44 @@ export default function GenerationPage() {
accessType: st.accessType,
shuffling: st.shuffling,
gradingSystem: st.gradingSystem,
passages: mod === "reading" ? st.passages.map((p) => ({ text: p.text, category: p.category, type: p.type, exercises: p.exercises })) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({ type: s.type, context: s.context, audioUrl: s.audioUrl })) : undefined,
tasks: mod === "writing" ? st.writingTasks.map((t) => ({ instructions: t.instructions, wordLimit: t.wordLimit, marks: t.marks })) : undefined,
parts: mod === "speaking" ? st.speakingParts.map((p) => ({ type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks })) : undefined,
rubricId: st.rubricId,
entity: st.entity,
approvalWorkflow: st.approvalWorkflow,
totalMarks: st.totalMarks,
passages: mod === "reading" ? st.passages.map((p) => ({
text: p.text, category: p.category, type: p.type,
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
})),
})) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({
type: s.type, context: s.context, audioUrl: s.audioUrl,
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
})),
})) : undefined,
tasks: mod === "writing" ? st.writingTasks.map((t) => ({
instructions: t.instructions, wordLimit: t.wordLimit,
marks: t.marks, category: t.category, type: t.type,
})) : undefined,
parts: mod === "speaking" ? st.speakingParts.map((p) => ({
type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks,
})) : undefined,
};
}
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
return generationService.submitExam({
title, label: examLabel,
exam_mode: examMode,
structure_id: examStructure ? Number(examStructure) : undefined,
modules: modulesPayload,
skip_approval: skipApproval,
});
},
onSuccess: (res) => toast({
title: "Exam submitted successfully",
@@ -599,27 +687,6 @@ export default function GenerationPage() {
generateAudioMut.isPending || generateWritingMut.isPending ||
generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending;
const renderDifficultyTags = (mod: ModuleKey) => {
const st = getModuleState(mod);
return (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState(mod, { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState(mod, { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
};
const renderCommonConfig = (mod: ModuleKey) => {
const st = getModuleState(mod);
return (
@@ -628,7 +695,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{renderDifficultyTags(mod)}
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Access Type</Label>
<Select value={st.accessType} onValueChange={(v) => updateModuleState(mod, { accessType: v })}>
@@ -643,21 +737,36 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState(mod, { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Approval Workflow</Label>
<Select value={st.approvalWorkflow} onValueChange={(v) => updateModuleState(mod, { approvalWorkflow: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="default">Default</SelectItem><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{approvalWorkflows.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Grading System</Label>
<Select value={st.gradingSystem} onValueChange={(v) => updateModuleState(mod, { gradingSystem: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="System Select" /></SelectTrigger>
<SelectContent><SelectItem value="ielts">IELTS Band</SelectItem><SelectItem value="percentage">Percentage</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="ielts">IELTS Band (1-9)</SelectItem>
<SelectItem value="percentage">Percentage (0-100%)</SelectItem>
<SelectItem value="pass_fail">Pass / Fail</SelectItem>
<SelectItem value="cefr">CEFR Level (A1-C2)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
@@ -1589,23 +1698,6 @@ export default function GenerationPage() {
const renderLevelModule = () => {
if (activeModule !== "level") return null;
const st = getModuleState("level");
const renderLevelDifficulty = () => (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState("level", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("level", { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
@@ -1613,7 +1705,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("level", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{renderLevelDifficulty()}
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("level", { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Number of Parts</Label>
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
@@ -1638,7 +1757,12 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState("level", { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
@@ -1797,21 +1921,34 @@ export default function GenerationPage() {
<Label className="text-xs">Timer (minutes)</Label>
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("industry", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
</div>
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Difficulty</Label>
<div className="flex flex-wrap gap-1">
{st.difficulty.map((d) => (
<Badge key={d} variant="secondary" className="text-xs gap-1">
{d}
<button onClick={() => updateModuleState("industry", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
</Badge>
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("industry", { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("industry", { difficulty: [...st.difficulty, v] }); }}>
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
<SelectContent>{INDUSTRY_DIFFICULTY_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-1">
<Label className="text-xs">Number of Parts</Label>
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
@@ -1836,7 +1973,12 @@ export default function GenerationPage() {
<Label className="text-xs">Entities</Label>
<Select value={st.entity} onValueChange={(v) => updateModuleState("industry", { entity: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
@@ -1946,6 +2088,110 @@ export default function GenerationPage() {
);
};
const handleStructureSelect = (val: string) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (Array.isArray(s.modules) ? s.modules : []) as string[];
if (mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
}
}
};
const handleModeSwitch = (mode: "official" | "practice") => {
setExamMode(mode);
if (mode === "practice") {
setExamStructure("");
}
setSelectedModules(new Set());
setActiveModule(null);
};
const renderOfficialStructurePreview = () => {
if (!selectedStructure || !selectedStructureConfig) return null;
const cfg = selectedStructureConfig;
const mods = (Array.isArray(selectedStructure.modules) ? selectedStructure.modules : []) as string[];
return (
<Card className="border bg-muted/30">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">{selectedStructure.name}</span>
{cfg.exam_type && <Badge variant="outline" className="capitalize text-xs">{cfg.exam_type}</Badge>}
{selectedStructure.industry && <Badge variant="secondary" className="text-xs">{selectedStructure.industry}</Badge>}
</div>
<span className="text-xs text-muted-foreground">Read-only structure</span>
</div>
<div className="flex gap-1.5 flex-wrap">
{mods.map((m) => (
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-1">
{cfg.listening && mods.includes("listening") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Headphones className="h-3.5 w-3.5 text-teal-600" /> Listening</div>
<p className="text-xs text-muted-foreground">{cfg.listening.parts?.length ?? 0} parts &middot; {cfg.listening.total_questions ?? 0} questions</p>
{cfg.listening.parts?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">P{i + 1}: {p.label}</p>
))}
</div>
)}
{cfg.reading && mods.includes("reading") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><BookOpen className="h-3.5 w-3.5 text-blue-600" /> Reading</div>
<p className="text-xs text-muted-foreground">{cfg.reading.passages?.length ?? 0} passages &middot; {cfg.reading.total_questions ?? 0} questions</p>
{cfg.reading.passages?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">{cfg.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style}</p>
))}
</div>
)}
{cfg.writing && mods.includes("writing") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><PenTool className="h-3.5 w-3.5 text-orange-600" /> Writing</div>
{cfg.writing.tasks?.map((t, i) => (
<p key={i} className="text-xs text-muted-foreground">{t.label || `Task ${i + 1}`}: {t.type} (min {t.min_words} words)</p>
))}
{!cfg.writing.tasks?.length && cfg.writing.task1 && (
<p className="text-xs text-muted-foreground">Task 1: {cfg.writing.task1.type} &middot; Task 2: {cfg.writing.task2?.type}</p>
)}
</div>
)}
{cfg.speaking && mods.includes("speaking") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Mic className="h-3.5 w-3.5 text-purple-600" /> Speaking</div>
{cfg.speaking.parts?.map((p, i) => (
<p key={i} className="text-xs text-muted-foreground">Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)</p>
))}
</div>
)}
{cfg.level && mods.includes("level") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Layers className="h-3.5 w-3.5 text-indigo-600" /> Level</div>
<p className="text-xs text-muted-foreground">
{Object.values(cfg.level.exercise_types || {}).reduce((s, v) => s + v, 0)} total questions
</p>
</div>
)}
{cfg.industry && mods.includes("industry") && (
<div className="rounded-md border bg-background p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium"><Briefcase className="h-3.5 w-3.5 text-amber-700" /> Industry</div>
<p className="text-xs text-muted-foreground">Difficulty: {cfg.industry.difficulty}</p>
</div>
)}
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -1956,9 +2202,34 @@ export default function GenerationPage() {
</div>
</div>
{/* Mode tabs */}
<div className="flex gap-2 border-b pb-0">
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
examMode === "official"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
}`}
onClick={() => handleModeSwitch("official")}
>
<GraduationCap className="h-4 w-4" /> Official Exam
</button>
<button
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
examMode === "practice"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
}`}
onClick={() => handleModeSwitch("practice")}
>
<Dumbbell className="h-4 w-4" /> Practice Exam
</button>
</div>
<AiTipBanner context="generation" variant="recommendation" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Title, Label, Structure row */}
<div className={`grid grid-cols-1 gap-4 ${examMode === "official" ? "md:grid-cols-3" : "md:grid-cols-2"}`}>
<div className="space-y-1">
<Label>Title <span className="text-destructive">*</span></Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Insert title" />
@@ -1967,20 +2238,15 @@ export default function GenerationPage() {
<Label>Exam Label</Label>
<Input value={examLabel} onChange={(e) => setExamLabel(e.target.value)} placeholder="Exam Label" />
</div>
{examMode === "official" && (
<div className="space-y-1">
<Label>Exam Structure</Label>
<Label>Exam Structure <span className="text-destructive">*</span></Label>
<Select value={examStructure} onValueChange={(val) => {
setExamStructure(val);
const s = structures.find((st) => String(st.id) === val);
if (s) {
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
if (Array.isArray(mods) && mods.length) {
const next = new Set<ModuleKey>();
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
}
if (val === "__add_new__") {
navigate("/admin/exam-structures");
return;
}
handleStructureSelect(val);
}}>
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
<SelectContent>
@@ -1990,13 +2256,31 @@ export default function GenerationPage() {
{structures.length === 0 && (
<SelectItem value="_none" disabled>No structures available</SelectItem>
)}
<div className="border-t my-1" />
<SelectItem value="__add_new__" className="text-primary font-medium">
<span className="flex items-center gap-1.5"><Plus className="h-3.5 w-3.5" /> Add New</span>
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Official mode: read-only structure preview */}
{examMode === "official" && selectedStructure && renderOfficialStructurePreview()}
{examMode === "official" && !selectedStructure && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
<GraduationCap className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Select an Exam Structure above to view its configuration and start generating.</p>
</CardContent>
</Card>
)}
{/* Practice mode: free module selection */}
{examMode === "practice" && (
<div>
<p className="text-sm text-muted-foreground mb-3">Please select which subjects the exam is about</p>
<p className="text-sm text-muted-foreground mb-3">Select which modules you want to practice</p>
<div className="flex flex-wrap gap-3">
{MODULES.map((m) => (
<button key={m.key}
@@ -2013,6 +2297,28 @@ export default function GenerationPage() {
))}
</div>
</div>
)}
{/* Official mode: module selector from structure's modules (locked, non-toggleable) */}
{examMode === "official" && isOfficialLocked && selectedModules.size > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-3 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5" /> Modules defined by structure &mdash; select one to configure
</p>
<div className="flex flex-wrap gap-3">
{MODULES.filter((m) => selectedModules.has(m.key)).map((m) => (
<button key={m.key}
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
activeModule === m.key ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
}`}
onClick={() => setActiveModule(m.key)}>
<span className={m.color}>{m.icon}</span>
<span className="text-sm font-medium">{m.label}</span>
</button>
))}
</div>
</div>
)}
{activeModule && selectedModules.has(activeModule) && (
<div className="flex gap-3">
@@ -2530,6 +2836,67 @@ export default function GenerationPage() {
)}
</DialogContent>
</Dialog>
<Dialog open={newStructureDialog.open} onOpenChange={(open) => {
if (!open) setNewStructureDialog({ open: false, name: "", modules: new Set() });
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>New Exam Structure</DialogTitle>
<DialogDescription>Create a new exam structure with selected modules.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Name <span className="text-destructive">*</span></Label>
<Input
value={newStructureDialog.name}
onChange={(e) => setNewStructureDialog((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Business English Assessment"
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Modules</Label>
<div className="grid grid-cols-2 gap-2">
{MODULES.map((m) => (
<label key={m.key} className={`flex items-center gap-2 rounded-lg border px-3 py-2 cursor-pointer transition-all ${
newStructureDialog.modules.has(m.key) ? `${m.bgColor} ${m.color}` : "hover:bg-muted/50"
}`}>
<Checkbox
checked={newStructureDialog.modules.has(m.key)}
onCheckedChange={(checked) => {
setNewStructureDialog((prev) => {
const next = new Set(prev.modules);
if (checked) next.add(m.key); else next.delete(m.key);
return { ...prev, modules: next };
});
}}
/>
{m.icon}
<span className="text-sm font-medium">{m.label}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewStructureDialog({ open: false, name: "", modules: new Set() })}>
Cancel
</Button>
<Button
disabled={!newStructureDialog.name.trim() || newStructureDialog.modules.size === 0 || createStructureMut.isPending}
onClick={() => {
createStructureMut.mutate({
name: newStructureDialog.name.trim(),
modules: [...newStructureDialog.modules],
});
}}
>
{createStructureMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating...</> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,48 +1,527 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import { examsService } from "@/services/exams.service";
import { useToast } from "@/hooks/use-toast";
interface RubricItem {
id: number;
name: string;
levels: string[];
skill: string;
exam_type: string;
criteria: number;
criteria_text: string;
levels: string[];
created: string;
skill?: string;
}
const FALLBACK_RUBRICS: RubricItem[] = [
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
interface CriterionEntry {
name: string;
weight: number;
descriptors: Record<string, string>;
}
const SKILL_OPTIONS = [
{ value: "writing", label: "Writing" },
{ value: "speaking", label: "Speaking" },
];
const rubricGroups = [
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
const EXAM_TYPE_OPTIONS = [
{ value: "academic", label: "Academic" },
{ value: "general_training", label: "General Training" },
{ value: "general_english", label: "General English" },
];
const ALL_CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
interface RubricFormState {
name: string;
skill: string;
exam_type: string;
criteria: CriterionEntry[];
levels: Set<string>;
}
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
name: "",
weight: 0,
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
});
const emptyForm = (): RubricFormState => ({
name: "",
skill: "writing",
exam_type: "academic",
criteria: [],
levels: new Set(ALL_CEFR_LEVELS),
});
function parseCriteriaText(text: string, levels: string[]): CriterionEntry[] {
if (!text) return [];
const mapStructured = (arr: unknown[]): CriterionEntry[] =>
arr.map((c) => {
const obj = c as Record<string, unknown>;
return {
name: String(obj.name || ""),
weight: Number(obj.weight || 0),
descriptors:
typeof obj.descriptors === "object" && obj.descriptors !== null
? Object.fromEntries(levels.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")]))
: Object.fromEntries(levels.map((l) => [l, ""])),
};
});
try {
const parsed = JSON.parse(text);
// Handle {"criteria": [...]} wrapper (from AI or previous saves)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.criteria)) {
return mapStructured(parsed.criteria);
}
if (Array.isArray(parsed)) {
if (parsed.length > 0 && typeof parsed[0] === "object" && parsed[0] !== null && "name" in parsed[0]) {
return mapStructured(parsed);
}
return parsed.filter(Boolean).map((name) => ({
name: String(name),
weight: Math.round(100 / parsed.length),
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
}));
}
} catch { /* not JSON */ }
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return [];
return lines.map((name) => ({
name,
weight: Math.round(100 / lines.length),
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
}));
}
interface GroupFormState {
name: string;
rubric_ids: Set<number>;
}
const emptyGroupForm = (): GroupFormState => ({ name: "", rubric_ids: new Set() });
export default function RubricsPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<RubricFormState>(emptyForm());
const [editTarget, setEditTarget] = useState<RubricItem | null>(null);
const [editForm, setEditForm] = useState<RubricFormState>(emptyForm());
const [groupCreateOpen, setGroupCreateOpen] = useState(false);
const [groupCreateForm, setGroupCreateForm] = useState<GroupFormState>(emptyGroupForm());
const [groupEditTarget, setGroupEditTarget] = useState<{ id: number; name: string; rubric_ids: number[] } | null>(null);
const [groupEditForm, setGroupEditForm] = useState<GroupFormState>(emptyGroupForm());
const rubricsQ = useQuery({
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const backendRubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
const rubrics = backendRubrics.length > 0 ? backendRubrics : FALLBACK_RUBRICS;
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
queryFn: () => examsService.listRubricGroups({}),
});
const rubricGroups = rubricGroupsQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) => examsService.createRubric(data as never),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
setCreateOpen(false);
setCreateForm(emptyForm());
toast({ title: "Rubric created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Create failed", description: err.message }),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
examsService.updateRubric(id, data as never),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
setEditTarget(null);
toast({ title: "Rubric updated" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => examsService.deleteRubric(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
toast({ title: "Rubric deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Delete failed", description: err.message }),
});
const createGroupMut = useMutation({
mutationFn: (data: { name: string; rubric_ids: number[] }) => examsService.createRubricGroup(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
setGroupCreateOpen(false);
setGroupCreateForm(emptyGroupForm());
toast({ title: "Rubric group created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Create group failed", description: err.message }),
});
const updateGroupMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: { name?: string; rubric_ids?: number[] } }) =>
examsService.updateRubricGroup(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
setGroupEditTarget(null);
toast({ title: "Rubric group updated" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Update group failed", description: err.message }),
});
const deleteGroupMut = useMutation({
mutationFn: (id: number) => examsService.deleteRubricGroup(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
toast({ title: "Rubric group deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Delete group failed", description: err.message }),
});
const [suggesting, setSuggesting] = useState(false);
const suggestCriteria = async (
form: RubricFormState,
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
) => {
setSuggesting(true);
try {
const result = await examsService.suggestRubricCriteria({
name: form.name,
skill: form.skill,
exam_type: form.exam_type,
levels: [...form.levels],
});
if (result.criteria && Array.isArray(result.criteria) && result.criteria.length > 0) {
const levelsArr = [...form.levels];
const entries: CriterionEntry[] = result.criteria.map((c: unknown) => {
if (typeof c === "object" && c !== null) {
const obj = c as Record<string, unknown>;
return {
name: String(obj.name || ""),
weight: Number(obj.weight || 0),
descriptors: typeof obj.descriptors === "object" && obj.descriptors !== null
? Object.fromEntries(
levelsArr.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")])
)
: Object.fromEntries(levelsArr.map((l) => [l, ""])),
};
}
return { name: String(c), weight: 0, descriptors: Object.fromEntries(levelsArr.map((l) => [l, ""])) };
});
setForm((f) => ({ ...f, criteria: entries }));
toast({ title: "Criteria generated", description: `${entries.length} criteria with band descriptors created by AI.` });
}
} catch (err) {
toast({ variant: "destructive", title: "AI suggestion failed", description: (err as Error).message });
} finally {
setSuggesting(false);
}
};
const openEdit = (r: RubricItem) => {
const levelsArr = Array.isArray(r.levels) && r.levels.length > 0 ? r.levels : ALL_CEFR_LEVELS;
setEditTarget(r);
setEditForm({
name: r.name,
skill: r.skill || "writing",
exam_type: r.exam_type || "academic",
criteria: parseCriteriaText(r.criteria_text, levelsArr),
levels: new Set(levelsArr),
});
};
const formToPayload = (form: RubricFormState) => ({
name: form.name.trim(),
skill: form.skill,
exam_type: form.exam_type,
criteria: JSON.stringify(form.criteria.filter((c) => c.name.trim())),
levels: [...form.levels],
});
const filtered = rubrics.filter((r) => {
if (!search) return true;
const q = search.toLowerCase();
return r.name?.toLowerCase().includes(q) || r.skill?.toLowerCase().includes(q);
});
const updateCriterion = (
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
index: number,
patch: Partial<CriterionEntry>,
) => {
setForm((f) => {
const criteria = [...f.criteria];
criteria[index] = { ...criteria[index], ...patch };
return { ...f, criteria };
});
};
const updateDescriptor = (
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
cIndex: number,
level: string,
value: string,
) => {
setForm((f) => {
const criteria = [...f.criteria];
criteria[cIndex] = {
...criteria[cIndex],
descriptors: { ...criteria[cIndex].descriptors, [level]: value },
};
return { ...f, criteria };
});
};
const renderForm = (
form: RubricFormState,
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
) => {
const levelsArr = [...form.levels].sort(
(a, b) => ALL_CEFR_LEVELS.indexOf(a) - ALL_CEFR_LEVELS.indexOf(b)
);
const totalWeight = form.criteria.reduce((s, c) => s + (c.weight || 0), 0);
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Rubric Name <span className="text-destructive">*</span></Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. IELTS Writing Task 1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Skill</Label>
<Select value={form.skill} onValueChange={(v) => setForm((f) => ({ ...f, skill: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SKILL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Exam Type</Label>
<Select value={form.exam_type} onValueChange={(v) => setForm((f) => ({ ...f, exam_type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{EXAM_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>CEFR Levels</Label>
<div className="flex flex-wrap gap-2">
{ALL_CEFR_LEVELS.map((level) => {
const checked = form.levels.has(level);
return (
<label
key={level}
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 cursor-pointer transition-all text-sm font-medium ${
checked ? "bg-primary/10 border-primary text-primary" : "hover:bg-muted/50"
}`}
>
<Checkbox
checked={checked}
onCheckedChange={(val) => {
setForm((f) => {
const next = new Set(f.levels);
if (val) next.add(level); else next.delete(level);
return { ...f, levels: next };
});
}}
/>
{level}
</label>
);
})}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Criteria</Label>
{form.criteria.length > 0 && (
<span className={`ml-2 text-xs ${totalWeight === 100 ? "text-green-600" : "text-amber-600"}`}>
(Total weight: {totalWeight}%)
</span>
)}
</div>
<Button
type="button"
size="sm"
className="h-8 bg-gradient-to-r from-violet-500 to-indigo-500 text-white hover:from-violet-600 hover:to-indigo-600"
disabled={suggesting}
onClick={() => suggestCriteria(form, setForm)}
>
{suggesting
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Generating...</>
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" /> Generate with AI</>
}
</Button>
</div>
{suggesting ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed border-violet-300 bg-violet-50/50">
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
<p className="text-sm font-medium">AI is generating criteria with band descriptors...</p>
<p className="text-xs">Based on {form.skill} / {form.exam_type.replace(/_/g, " ")}</p>
</div>
) : form.criteria.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
<Sparkles className="h-5 w-5 text-violet-400" />
<p className="text-sm">No criteria yet.</p>
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or add manually.</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Manually
</Button>
</div>
) : (
<div className="space-y-3">
{form.criteria.map((c, ci) => (
<Collapsible key={ci} defaultOpen={form.criteria.length <= 3}>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 p-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<Input
value={c.name}
onChange={(e) => updateCriterion(setForm, ci, { name: e.target.value })}
placeholder="Criterion name"
className="flex-1 h-8 font-medium"
/>
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
min={0}
max={100}
value={c.weight}
onChange={(e) => updateCriterion(setForm, ci, { weight: Number(e.target.value) })}
className="w-16 h-8 text-center text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setForm((f) => ({ ...f, criteria: f.criteria.filter((_, i) => i !== ci) }))}
>
<X className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent>
<div className="px-3 pb-3 pt-0 border-t">
<p className="text-xs text-muted-foreground my-2">Band level descriptors:</p>
<div className="space-y-2">
{levelsArr.map((level) => (
<div key={level} className="flex gap-2 items-start">
<Badge variant="outline" className="mt-1.5 shrink-0 w-9 justify-center text-xs">{level}</Badge>
<Textarea
value={c.descriptors[level] || ""}
onChange={(e) => updateDescriptor(setForm, ci, level, e.target.value)}
placeholder={`Performance descriptor for ${level}...`}
className="min-h-[40px] h-10 text-sm resize-none"
/>
</div>
))}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Criterion
</Button>
</div>
)}
</div>
</div>
);
};
return (
<div className="space-y-6">
@@ -53,25 +532,25 @@ export default function RubricsPage() {
</div>
<div className="flex gap-2">
<AiCreationAssistant type="rubric" />
<Dialog>
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
<div className="space-y-3">
<Label>Level Descriptors</Label>
{["A1","A2","B1","B2","C1","C2"].map(level => (
<div key={level} className="space-y-1">
<Label className="text-xs text-muted-foreground">{level}</Label>
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
</div>
))}
</div>
<Button className="w-full">Create Rubric</Button>
</div>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Rubric</DialogTitle>
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
</DialogHeader>
{renderForm(createForm, setCreateForm)}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
onClick={() => createMut.mutate(formToPayload(createForm))}
>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@@ -90,51 +569,248 @@ export default function RubricsPage() {
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
</TabsList>
<TabsContent value="rubrics" className="mt-4">
{rubricsQ.isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{rubricsQ.error && (
<Card className="border-destructive">
<CardContent className="p-4 text-sm text-destructive">
Failed to load rubrics. The backend endpoint may not be available yet.
</CardContent>
</Card>
)}
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No rubrics found. Create one to get started.
</CardContent>
</Card>
)}
{!rubricsQ.isLoading && filtered.length > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
<TableHead>Name</TableHead>
<TableHead>Skill</TableHead>
<TableHead>Exam Type</TableHead>
<TableHead>Levels</TableHead>
<TableHead>Criteria</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[90px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubrics.map((r) => (
<TableRow key={r.id}>
{filtered.map((r) => (
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
<TableCell><Badge variant="secondary" className="capitalize">{r.skill || "—"}</Badge></TableCell>
<TableCell className="capitalize text-sm">{r.exam_type?.replace(/_/g, " ") || "—"}</TableCell>
<TableCell>
<div className="flex gap-1">
{(r.levels || ALL_CEFR_LEVELS).map((l) => (
<Badge key={l} variant="outline" className="text-xs">{l}</Badge>
))}
</div>
</TableCell>
<TableCell>{r.criteria}</TableCell>
<TableCell>{r.created}</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.created}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(r.id); }}
disabled={deleteMut.isPending}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="groups" className="mt-4">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Create Group
</Button>
</div>
{rubricGroupsQ.isLoading && (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
)}
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
<Card className="border-dashed"><CardContent className="p-8 text-center text-muted-foreground text-sm">No rubric groups yet. Create one to bundle rubrics together.</CardContent></Card>
)}
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
<TableHead>Group Name</TableHead>
<TableHead>Rubrics</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubricGroups.map((g) => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{(g.rubric_names ?? []).map((rb) => (
<Badge key={rb} variant="secondary" className="text-xs">{rb}</Badge>
))}
{(!g.rubric_names || g.rubric_names.length === 0) && (
<span className="text-xs text-muted-foreground italic">No rubrics</span>
)}
</div>
</TableCell>
<TableCell>{g.created}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
setGroupEditTarget({ id: g.id, name: g.name, rubric_ids: g.rubric_ids });
setGroupEditForm({ name: g.name, rubric_ids: new Set(g.rubric_ids) });
}}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => { if (confirm(`Delete group "${g.name}"?`)) deleteGroupMut.mutate(g.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Create Group Dialog */}
<Dialog open={groupCreateOpen} onOpenChange={setGroupCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Rubric Group</DialogTitle>
<DialogDescription>Bundle multiple rubrics into a group for easy selection.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Group Name <span className="text-destructive">*</span></Label>
<Input value={groupCreateForm.name} onChange={(e) => setGroupCreateForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Academic IELTS Full" />
</div>
<div className="space-y-1">
<Label>Select Rubrics</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{rubrics.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox checked={groupCreateForm.rubric_ids.has(r.id)}
onCheckedChange={(checked) => {
setGroupCreateForm((p) => {
const next = new Set(p.rubric_ids);
checked ? next.add(r.id) : next.delete(r.id);
return { ...p, rubric_ids: next };
});
}} className="h-3.5 w-3.5" />
{r.name}
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
</label>
))}
{rubrics.length === 0 && <p className="text-xs text-muted-foreground italic py-2 text-center">No rubrics available</p>}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupCreateOpen(false)}>Cancel</Button>
<Button disabled={!groupCreateForm.name.trim() || createGroupMut.isPending}
onClick={() => createGroupMut.mutate({ name: groupCreateForm.name.trim(), rubric_ids: [...groupCreateForm.rubric_ids] })}>
{createGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create Group"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Group Dialog */}
<Dialog open={!!groupEditTarget} onOpenChange={(open) => { if (!open) setGroupEditTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Rubric Group</DialogTitle>
<DialogDescription>Update the group name and selected rubrics.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Group Name <span className="text-destructive">*</span></Label>
<Input value={groupEditForm.name} onChange={(e) => setGroupEditForm((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Select Rubrics</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{rubrics.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox checked={groupEditForm.rubric_ids.has(r.id)}
onCheckedChange={(checked) => {
setGroupEditForm((p) => {
const next = new Set(p.rubric_ids);
checked ? next.add(r.id) : next.delete(r.id);
return { ...p, rubric_ids: next };
});
}} className="h-3.5 w-3.5" />
{r.name}
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupEditTarget(null)}>Cancel</Button>
<Button disabled={!groupEditForm.name.trim() || updateGroupMut.isPending}
onClick={() => {
if (!groupEditTarget) return;
updateGroupMut.mutate({ id: groupEditTarget.id, data: { name: groupEditForm.name.trim(), rubric_ids: [...groupEditForm.rubric_ids] } });
}}>
{updateGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
</Tabs>
<Dialog open={!!editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null); }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Rubric</DialogTitle>
<DialogDescription>Update the rubric details below. Use AI to regenerate criteria.</DialogDescription>
</DialogHeader>
{renderForm(editForm, setEditForm)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)}>Cancel</Button>
<Button
disabled={!editForm.name.trim() || editForm.criteria.filter((c) => c.name.trim()).length === 0 || updateMut.isPending}
onClick={() => {
if (!editTarget) return;
updateMut.mutate({ id: editTarget.id, data: formToPayload(editForm) });
}}
>
{updateMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -39,20 +39,35 @@ interface FeedbackEntry {
source: string;
}
interface AnswerEntry {
question_id: number;
answer: string;
score: number;
is_correct: boolean;
feedback: string;
}
interface ExamResultsData {
attempt_id: number;
exam_id: number | null;
exam_title?: string;
status: string;
completed_at: string;
released_at: string;
listening_band: number;
reading_band: number;
writing_band: number;
speaking_band: number;
overall_band: number;
cefr_level: string;
scores: ScoreEntry[];
feedback: FeedbackEntry[];
completed_at?: string;
released_at?: string;
started_at?: string | null;
finished_at?: string | null;
total_score?: number;
max_score?: number;
percentage?: number;
listening_band?: number;
reading_band?: number;
writing_band?: number;
speaking_band?: number;
overall_band?: number;
cefr_level?: string;
scores?: ScoreEntry[];
feedback?: FeedbackEntry[];
answers?: AnswerEntry[];
}
export default function ExamResults() {
@@ -99,11 +114,13 @@ export default function ExamResults() {
);
}
const overall = results.overall_band;
const hasDetailedScores = Array.isArray(results.scores) && results.scores.length > 0;
const overall = results.overall_band ?? 0;
const pct = results.percentage ?? (results.max_score ? Math.round((results.total_score ?? 0) / results.max_score * 100) : 0);
const cefr = results.cefr_level?.toUpperCase() || "N/A";
const passed = overall >= 7;
const passed = hasDetailedScores ? overall >= 7 : pct >= 70;
const skillScores = results.scores.filter((s) => s.skill !== "overall");
const skillScores = hasDetailedScores ? results.scores!.filter((s) => s.skill !== "overall") : [];
const SKILLS = skillScores.map((s) => ({
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
band: s.band_score,
@@ -114,25 +131,39 @@ export default function ExamResults() {
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
const feedbackList = results.feedback ?? [];
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
for (const fb of results.feedback) {
for (const fb of feedbackList) {
const key = "General";
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
feedbackBySkill[key].push(fb);
}
const answerEntries = results.answers ?? [];
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="text-center">
{results.exam_title && (
<h1 className="text-xl font-semibold mb-1">{results.exam_title}</h1>
)}
<p className="text-sm text-muted-foreground">Your overall result</p>
{hasDetailedScores ? (
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">
Band
</Badge>
<Badge variant="secondary" className="text-lg">Band</Badge>
</div>
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
) : (
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{pct}%</span>
</div>
)}
<p className="mt-1 text-sm text-muted-foreground">
{results.total_score ?? 0} / {results.max_score ?? 0} marks
</p>
{hasDetailedScores && <p className="mt-1 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>}
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
</div>
@@ -185,11 +216,48 @@ export default function ExamResults() {
</CardContent>
</Card>
{results.feedback.length > 0 && (
{answerEntries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Your Answers</CardTitle>
<CardDescription>
{answerEntries.filter((a) => a.is_correct).length} correct out of {answerEntries.length}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Your Answer</TableHead>
<TableHead>Score</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{answerEntries.map((a, i) => (
<TableRow key={a.question_id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="max-w-[200px] truncate">{a.answer || "—"}</TableCell>
<TableCell>{a.score}</TableCell>
<TableCell>
<Badge variant={a.is_correct ? "default" : "destructive"}>
{a.is_correct ? "Correct" : "Incorrect"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{feedbackList.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
<Accordion type="multiple" className="w-full">
{results.feedback.map((fb, i) => (
{feedbackList.map((fb, i) => (
<AccordionItem key={i} value={`fb-${i}`}>
<AccordionTrigger>
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
import type { ExamAnswer, ExamOptionItem, ExamQuestion, ExamSessionSection } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
@@ -27,6 +27,11 @@ function normalizeType(t: string | null | undefined) {
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
}
function normalizeOption(o: ExamOptionItem): { label: string; text: string } {
if (typeof o === "string") return { label: o, text: o };
return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" };
}
function countWords(s: string) {
return s.trim() ? s.trim().split(/\s+/).length : 0;
}
@@ -222,18 +227,19 @@ export default function ExamSession() {
const a = answers.get(q.id);
if (!a) return null;
const nt = normalizeType(q.type);
const opts = (q.options ?? []).map(normalizeOption);
if (nt.includes("listen") || q.audio_url) {
return (
<div className="space-y-4">
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
{q.options?.length ? (
{opts.length ? (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options.map((o) => (
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
@@ -249,7 +255,7 @@ export default function ExamSession() {
const selected = Array.isArray(a.answer) ? a.answer : [];
return (
<div className="space-y-3">
{q.options?.map((o) => (
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-${o.label}`}
@@ -267,7 +273,7 @@ export default function ExamSession() {
);
}
if (nt.includes("gap")) {
if (nt.includes("gap") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) {
return (
<Input
value={typeof a.answer === "string" ? a.answer : ""}
@@ -279,12 +285,12 @@ export default function ExamSession() {
}
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
const opts = q.options?.length
? q.options
const tfOpts = opts.length
? opts
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
{ label: "TRUE", text: "TRUE" },
{ label: "FALSE", text: "FALSE" },
{ label: "NOT GIVEN", text: "NOT GIVEN" },
];
return (
<RadioGroup
@@ -292,7 +298,7 @@ export default function ExamSession() {
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{opts.map((o) => (
{tfOpts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
@@ -338,7 +344,7 @@ export default function ExamSession() {
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options?.map((o) => (
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
@@ -389,13 +395,39 @@ export default function ExamSession() {
</header>
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
<ScrollArea className="flex-1 p-6">
{section?.passage_text ? (
<ScrollArea className="w-full md:w-1/2 border-r p-6">
<div className="prose prose-sm dark:prose-invert max-w-none">
<h3 className="text-base font-semibold mb-2">{section.title}</h3>
{section.difficulty && (
<span className="inline-block text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded mb-3">
Level: {section.difficulty}
</span>
)}
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-line">
{section.passage_text}
</div>
</div>
</ScrollArea>
) : null}
<ScrollArea className={cn("flex-1 p-6", section?.passage_text && "md:w-1/2")}>
{question ? (
<Card className="border-none shadow-none">
<CardContent className="space-y-6 p-0">
{question.passage_text ? (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
{section?.instructions_text ? (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-sm leading-relaxed border border-blue-200 dark:border-blue-800">
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">Instructions</p>
{section.instructions_text}
</div>
) : null}
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-primary text-primary-foreground text-xs font-bold">
{questionIdx + 1}
</span>
<span className="text-xs text-muted-foreground">
of {section?.questions.length ?? 0} · {question.marks} mark{question.marks !== 1 ? "s" : ""}
</span>
</div>
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="font-medium">{question.stem}</p>
</div>

View File

@@ -1,11 +1,26 @@
import { api } from "@/lib/api-client";
import type { Assignment, AssignmentState, AssignmentCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
import type {
Assignment,
AssignmentState,
AssignmentCreateRequest,
ExamSchedule,
ExamScheduleCreateRequest,
ScheduleState,
StudentExamAssignment,
PaginatedResponse,
PaginationParams,
ApiSuccessResponse,
} from "@/types";
export interface AssignmentListParams extends PaginationParams {
state?: AssignmentState;
entity_id?: number;
}
export interface ScheduleListParams extends PaginationParams {
state?: ScheduleState;
}
export const assignmentsService = {
async list(params?: AssignmentListParams): Promise<PaginatedResponse<Assignment>> {
return api.get<PaginatedResponse<Assignment>>("/assignments", params as Record<string, string | number | boolean | undefined>);
@@ -34,4 +49,28 @@ export const assignmentsService = {
async start(id: number): Promise<Assignment> {
return api.post<Assignment>(`/assignments/${id}/start`);
},
async listSchedules(params?: ScheduleListParams): Promise<PaginatedResponse<ExamSchedule>> {
return api.get<PaginatedResponse<ExamSchedule>>("/exam-schedules", params as Record<string, string | number | boolean | undefined>);
},
async createSchedule(data: ExamScheduleCreateRequest): Promise<ExamSchedule> {
return api.post<ExamSchedule>("/exam-schedules", data);
},
async updateSchedule(id: number, data: Partial<ExamScheduleCreateRequest & { state: ScheduleState }>): Promise<ExamSchedule> {
return api.put<ExamSchedule>(`/exam-schedules/${id}`, data);
},
async deleteSchedule(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/exam-schedules/${id}`);
},
async archiveSchedule(id: number): Promise<ExamSchedule> {
return api.post<ExamSchedule>(`/exam-schedules/${id}/archive`);
},
async getStudentExams(): Promise<{ items: StudentExamAssignment[] }> {
return api.get<{ items: StudentExamAssignment[] }>("/student/my-exams");
},
};

View File

@@ -40,10 +40,30 @@ export const examsService = {
return api.post<Rubric>("/rubrics", data);
},
async updateRubric(id: number, data: Partial<Rubric>): Promise<Rubric> {
return api.put<Rubric>(`/rubrics/${id}`, data);
},
async deleteRubric(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/rubrics/${id}`);
},
async listRubricGroups(params?: PaginationParams): Promise<PaginatedResponse<RubricGroup>> {
return api.get<PaginatedResponse<RubricGroup>>("/rubric-groups", params as Record<string, string | number | boolean | undefined>);
},
async createRubricGroup(data: { name: string; rubric_ids: number[] }): Promise<RubricGroup> {
return api.post<RubricGroup>("/rubric-groups", data);
},
async updateRubricGroup(id: number, data: { name?: string; rubric_ids?: number[] }): Promise<RubricGroup> {
return api.put<RubricGroup>(`/rubric-groups/${id}`, data);
},
async deleteRubricGroup(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/rubric-groups/${id}`);
},
async listStructures(params?: PaginationParams & { entity_id?: number }): Promise<PaginatedResponse<ExamStructure>> {
return api.get<PaginatedResponse<ExamStructure>>("/exam-structures", params as Record<string, string | number | boolean | undefined>);
},
@@ -52,10 +72,23 @@ export const examsService = {
return api.post<ExamStructure>("/exam-structures", data);
},
async updateStructure(id: number, data: Partial<ExamStructure>): Promise<ExamStructure> {
return api.put<ExamStructure>(`/exam-structures/${id}`, data);
},
async deleteStructure(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/exam-structures/${id}`);
},
async suggestRubricCriteria(data: {
name?: string;
skill: string;
exam_type: string;
levels: string[];
}): Promise<{ criteria: string[]; suggested_levels?: string[] }> {
return api.post("/ai/suggest-rubric-criteria", data);
},
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string }[]> {
return api.get("/exam/avatars");
},

View File

@@ -137,9 +137,11 @@ export const generationService = {
submitExam(data: {
title: string;
label: string;
exam_mode?: string;
structure_id?: number;
modules: Record<string, unknown>;
skip_approval?: boolean;
}): Promise<{ exam_id: number; status: string; template_id?: number }> {
}): Promise<{ exam_id: number; status: string; template_id?: number; total_questions?: number }> {
return api.post("/exam/generation/submit", data);
},
};

View File

@@ -35,3 +35,60 @@ export interface AssignmentCreateRequest {
exam_ids: number[];
assignee_ids?: number[];
}
export type ScheduleState = "planned" | "active" | "past" | "start_expired" | "archived";
export interface ExamSchedule {
id: number;
name: string;
exam_id: number | null;
exam_title: string;
entity_id: number | null;
entity_name: string;
start_date: string | null;
end_date: string | null;
state: ScheduleState;
assign_mode: "entity" | "batch" | "individual";
full_length: boolean;
generate_different: boolean;
auto_release_results: boolean;
auto_start: boolean;
official_exam: boolean;
hide_assignee_details: boolean;
batch_ids: number[];
batch_names: string[];
student_ids: number[];
assignee_count: number;
completed_count: number;
created: string;
}
export interface ExamScheduleCreateRequest {
name: string;
exam_id: number;
entity_id?: number;
start_date: string;
end_date: string;
assign_mode: "entity" | "batch" | "individual";
batch_ids?: number[];
student_ids?: number[];
full_length?: boolean;
generate_different?: boolean;
auto_release_results?: boolean;
auto_start?: boolean;
official_exam?: boolean;
hide_assignee_details?: boolean;
}
export interface StudentExamAssignment {
id: number;
exam_id: number;
exam_title: string;
schedule_name: string;
start_date: string | null;
end_date: string | null;
status: string;
schedule_state: ScheduleState;
auto_start: boolean;
can_start: boolean;
}

View File

@@ -10,20 +10,33 @@ export interface ExamSessionSection {
id: number;
title: string;
skill: string;
difficulty?: string;
time_limit_sec: number;
time_limit_min?: number;
total_marks?: number;
scoring_method?: string;
sequence?: number;
passage_text?: string;
instructions_text?: string;
questions: ExamQuestion[];
}
export type ExamOptionItem = { label: string; text: string } | string;
export interface ExamQuestion {
id: number;
type: string;
question_type?: string;
stem: string;
options?: { label: string; text: string }[];
options?: ExamOptionItem[];
passage_text?: string;
audio_url?: string;
audio_base64?: string;
visual_url?: string;
min_words?: number;
marks: number;
difficulty?: string;
source_type?: string;
}
export interface ExamAnswer {

View File

@@ -37,6 +37,8 @@ export interface RubricGroup {
id: number;
name: string;
rubric_ids: number[];
rubric_names?: string[];
created?: string;
}
export interface ExamStructure {
@@ -44,6 +46,77 @@ export interface ExamStructure {
name: string;
entity_id: number;
entity_name: string;
industry: string;
modules: ExamModule[];
config: ExamStructureConfig;
created_at: string;
}
export interface ListeningPartConfig {
key: string;
label: string;
questions: number;
question_types: Record<string, number>;
}
export interface ReadingPassageConfig {
style: string;
questions: number;
question_types: Record<string, number>;
}
export interface SpeakingPartConfig {
key: string;
label: string;
duration_min: number;
duration_max: number;
}
export interface WritingTaskConfig {
type: string;
min_words: number;
label?: string;
}
export interface LevelExerciseEntry {
type: string;
quantity: number;
}
export interface IndustryExerciseEntry {
type: string;
quantity: number;
}
export interface ExamStructureConfig {
exam_type: "academic" | "general";
listening?: {
parts: ListeningPartConfig[];
total_questions: number;
};
reading?: {
passages: ReadingPassageConfig[];
total_questions: number;
};
writing?: {
tasks: WritingTaskConfig[];
/** @deprecated kept for backward compat reads */
task1?: { type: string; min_words: number };
/** @deprecated kept for backward compat reads */
task2?: { type: string; min_words: number };
rubric_id: number | null;
};
speaking?: {
parts: SpeakingPartConfig[];
rubric_id: number | null;
};
level?: {
exercise_types: Record<string, number>;
entries?: LevelExerciseEntry[];
};
industry?: {
exercise_types: Record<string, number>;
entries?: IndustryExerciseEntry[];
difficulty: string;
};
}

View File

@@ -5,7 +5,7 @@ db_host = localhost
db_port = 5432
http_interface = 127.0.0.1
http_port = 8069
addons_path = /Users/yamenahmad/projects2026/odoo/odoo19/addons_extra,/Users/yamenahmad/projects2026/odoo/odoo19/addons_enterprise,/Users/yamenahmad/projects2026/odoo/odoo19/odoo/addons
addons_path = /Users/yamenahmad/projects2026/odoo/odoo19/backend/custom_addons,/Users/yamenahmad/projects2026/odoo/odoo19/backend/openeducat_erp-19.0/openeducat_erp-19.0,/Users/yamenahmad/projects2026/odoo/odoo19/addons_extra,/Users/yamenahmad/projects2026/odoo/odoo19/addons_enterprise,/Users/yamenahmad/projects2026/odoo/odoo19/odoo/addons
admin_passwd = $pbkdf2-sha512$600000$W4ux9h5DyDmnFIIQ4hxDaA$bF8qJJWZLTs2IC8T74YWv1my44u4vsqvLXUfexx2I1kGvPXMwHJiZOMhaYxmC3GAuIxQI1/8HPvdQhqB8OoVMQ
workers = 4
max_cron_threads = 1