From 50f58dc99548702395ed5fb7a2175b1829b66b56 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Thu, 16 Apr 2026 16:53:09 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20exam=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20AI=20generation,=20submission,=20student=20session,=20and=20?= =?UTF-8?q?results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../encoach_ai/controllers/ai_controller.py | 671 +++++++- .../encoach_ai/models/__init__.py | 1 + .../encoach_ai/models/constants.py | 13 + .../encoach_ai/services/openai_service.py | 3 + .../encoach_core/data/encoach_permissions.xml | 2 +- .../encoach_exam_template/__manifest__.py | 1 + .../controllers/__init__.py | 4 + .../controllers/approval_workflows.py | 284 ++++ .../controllers/entities.py | 34 + .../controllers/exam_schedules.py | 312 ++++ .../controllers/exam_session.py | 316 ++++ .../controllers/exam_structures.py | 81 +- .../controllers/rubrics.py | 162 +- .../data/ir_cron_schedule.xml | 14 + .../encoach_exam_template/models/__init__.py | 3 + .../models/exam_assignment.py | 1 + .../models/exam_custom.py | 19 + .../models/exam_custom_section.py | 6 + .../models/exam_schedule.py | 68 + .../encoach_exam_template/models/rubric.py | 1 + .../models/rubric_group.py | 15 + .../models/student_attempt.py | 52 + .../security/ir.model.access.csv | 5 + .../controllers/exam_session.py | 21 +- frontend/src/components/StudentLayout.tsx | 8 +- frontend/src/components/student/ExamPopup.tsx | 119 ++ frontend/src/pages/ApprovalWorkflowsPage.tsx | 565 ++++++- frontend/src/pages/AssignmentsPage.tsx | 479 +++++- frontend/src/pages/ExamStructuresPage.tsx | 1423 +++++++++++++++-- frontend/src/pages/GenerationPage.tsx | 581 +++++-- frontend/src/pages/RubricsPage.tsx | 818 +++++++++- frontend/src/pages/student/ExamResults.tsx | 116 +- frontend/src/pages/student/ExamSession.tsx | 62 +- frontend/src/services/assignments.service.ts | 41 +- frontend/src/services/exams.service.ts | 33 + frontend/src/services/generation.service.ts | 4 +- frontend/src/types/assignment.ts | 57 + frontend/src/types/exam-session.ts | 15 +- frontend/src/types/exam.ts | 73 + odoo.conf | 2 +- 40 files changed, 5982 insertions(+), 503 deletions(-) create mode 100644 backend/custom_addons/encoach_ai/models/constants.py create mode 100644 backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py create mode 100644 backend/custom_addons/encoach_exam_template/controllers/entities.py create mode 100644 backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py create mode 100644 backend/custom_addons/encoach_exam_template/controllers/exam_session.py create mode 100644 backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml create mode 100644 backend/custom_addons/encoach_exam_template/models/exam_schedule.py create mode 100644 backend/custom_addons/encoach_exam_template/models/rubric_group.py create mode 100644 backend/custom_addons/encoach_exam_template/models/student_attempt.py create mode 100644 frontend/src/components/student/ExamPopup.tsx diff --git a/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py index 32cf790a..d48684ff 100644 --- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/ai_controller.py @@ -314,38 +314,194 @@ class AIController(http.Controller): _logger.exception("workbench publish failed") return _json_response({"status": "error", "error": str(e)}, 500) + # ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ── + @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False) + def ai_suggest_rubric_criteria(self, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) + + body = _get_json() + name = body.get("name", "") + skill = body.get("skill", "writing") + exam_type = body.get("exam_type", "academic") + levels = body.get("levels", ["A1", "A2", "B1", "B2", "C1", "C2"]) + + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + if not ai.client: + raise RuntimeError("OpenAI not configured") + + band_keys = ", ".join(f'"{lv}"' for lv in levels) + messages = [ + {"role": "system", "content": ( + "You are an expert in English language assessment rubric design. " + "Generate scoring criteria for a rubric. Return 3-6 criteria.\n\n" + "Each criterion must have:\n" + "- name: short name (e.g. 'Task Achievement')\n" + "- weight: percentage weight (all weights must sum to 100)\n" + "- descriptors: an object mapping ONLY these band levels to a 1-sentence description of expected performance at that level\n\n" + f"The ONLY allowed band level keys are: {band_keys}\n\n" + "Return ONLY this JSON structure:\n" + '{"criteria": [{"name": "string", "weight": number, ' + '"descriptors": {"LEVEL": "one sentence description", ...}}]}' + )}, + {"role": "user", "content": json.dumps({ + "rubric_name": name, + "skill": skill, + "exam_type": exam_type, + "target_levels": levels, + })}, + ] + result = ai.chat_json(messages, action="suggest_rubric_criteria") + return _json_response(result) + except Exception as e: + _logger.warning("AI unavailable (%s), using template criteria for %s/%s", e, skill, exam_type) + return _json_response({"criteria": self._fallback_criteria(skill, levels)}) + + @staticmethod + def _fallback_criteria(skill, levels): + """Return pre-built criteria templates when OpenAI is unavailable.""" + def _desc(level_map, levels): + return {lv: level_map.get(lv, "") for lv in levels} + + templates = { + "writing": [ + {"name": "Task Achievement", "weight": 25, "descriptors_map": { + "C2": "Fully addresses all parts with a well-developed position", + "C1": "Addresses all parts with a clear position throughout", + "B2": "Addresses all parts, though some more fully than others", + "B1": "Addresses the task only partially with limited development", + "A2": "Barely responds to the task with very limited ideas", + "A1": "Does not adequately address the task requirements", + }}, + {"name": "Coherence & Cohesion", "weight": 25, "descriptors_map": { + "C2": "Skillfully manages paragraphing with seamless cohesion", + "C1": "Logically organizes information with clear progression", + "B2": "Arranges information coherently with some cohesive devices", + "B1": "Presents information with some organization but may lack clarity", + "A2": "Limited ability to organize ideas; unclear progression", + "A1": "No apparent logical organization of ideas", + }}, + {"name": "Lexical Resource", "weight": 25, "descriptors_map": { + "C2": "Uses a wide range of vocabulary with very natural and sophisticated control", + "C1": "Uses a sufficient range of vocabulary to allow flexibility and precision", + "B2": "Uses an adequate range of vocabulary for the task with some errors", + "B1": "Uses a limited range of vocabulary with noticeable errors", + "A2": "Uses only basic vocabulary with frequent errors in word choice", + "A1": "Extremely limited vocabulary; barely able to convey meaning", + }}, + {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": { + "C2": "Wide range of structures with full flexibility and accuracy", + "C1": "Uses a variety of complex structures with good control", + "B2": "Uses a mix of simple and complex sentences with some errors", + "B1": "Attempts complex sentences but errors are frequent", + "A2": "Uses only simple sentences with many errors", + "A1": "Cannot use sentence forms except in memorized phrases", + }}, + ], + "speaking": [ + {"name": "Fluency & Coherence", "weight": 25, "descriptors_map": { + "C2": "Speaks effortlessly with natural flow and fully coherent speech", + "C1": "Speaks at length without noticeable effort or loss of coherence", + "B2": "Speaks with some hesitation but maintains coherent speech", + "B1": "Can keep going but pauses frequently to plan and correct", + "A2": "Produces simple utterances with long pauses", + "A1": "Speech is extremely slow with very long pauses", + }}, + {"name": "Lexical Resource", "weight": 25, "descriptors_map": { + "C2": "Uses vocabulary with full flexibility and precision in all topics", + "C1": "Uses vocabulary flexibly to discuss a variety of topics", + "B2": "Has a wide enough vocabulary to discuss topics at length", + "B1": "Uses sufficient vocabulary for familiar topics", + "A2": "Uses basic vocabulary for personal information and routine situations", + "A1": "Can only produce isolated words and memorized phrases", + }}, + {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": { + "C2": "Maintains consistent use of a wide range of accurate structures", + "C1": "Uses a wide range of structures with a majority of error-free sentences", + "B2": "Uses a range of structures with reasonable accuracy", + "B1": "Produces basic sentence forms with reasonable accuracy", + "A2": "Produces basic sentences with frequent errors", + "A1": "Cannot produce basic sentence forms", + }}, + {"name": "Pronunciation", "weight": 25, "descriptors_map": { + "C2": "Is effortless to understand with natural pronunciation features", + "C1": "Uses a wide range of pronunciation features with fine control", + "B2": "Is generally easy to understand with some L1 influence", + "B1": "Shows some effective use of features but may be inconsistent", + "A2": "Pronunciation is generally understood but often faulty", + "A1": "Speech is often unintelligible due to pronunciation errors", + }}, + ], + } + + base = templates.get(skill, templates["writing"]) + return [ + { + "name": c["name"], + "weight": c["weight"], + "descriptors": _desc(c["descriptors_map"], levels), + } + for c in base + ] + # ── Exam generation — GenerationPage.tsx ── - @http.route("/api/exam//generate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam//generate", type="http", auth="none", methods=["POST"], csrf=False) def exam_generate(self, module, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) + has_ai = bool(ai.client) + except Exception: + ai, has_ai = None, False + try: if body.get("generate_passage"): - return self._generate_passage(ai, body) + if has_ai: + return self._generate_passage(ai, body) + return _json_response(self._fallback_passage(body)) if body.get("generate_instructions"): - return self._generate_writing_instructions(ai, body) + if has_ai: + return self._generate_writing_instructions(ai, body) + return _json_response(self._fallback_writing_instructions(body)) if body.get("generate_script"): - return self._generate_speaking_script(ai, body) + if has_ai: + return self._generate_speaking_script(ai, body) + return _json_response(self._fallback_speaking_script(body)) if body.get("generate_context"): - return self._generate_listening_context(ai, body) + if has_ai: + return self._generate_listening_context(ai, body) + return _json_response(self._fallback_listening_context(body)) if body.get("generate_exercises"): - return self._generate_exercises(ai, module, body) + if has_ai: + return self._generate_exercises(ai, module, body) + return _json_response(self._fallback_exercises(module, body)) difficulty = body.get("difficulty", "B2") topic = body.get("topic", "") count = body.get("count") or body.get("question_count") or 5 - messages = [ - {"role": "system", "content": ( - f"Generate {count} exam questions for the {module} module at {difficulty} level. " - f"Return JSON: " - '{"questions": [{"type": string, "prompt": string, "options": [string], ' - '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' - )}, - {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, - ] - return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) + if has_ai: + messages = [ + {"role": "system", "content": ( + f"Generate {count} exam questions for the {module} module at {difficulty} level. " + f"Return JSON: " + '{"questions": [{"type": string, "prompt": string, "options": [string], ' + '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' + )}, + {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, + ] + return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) + return _json_response(self._fallback_questions(module, body)) except Exception as e: _logger.exception("exam_generate %s failed: %s", module, e) return _json_response({"questions": [], "error": str(e)}, 500) @@ -449,9 +605,314 @@ class AIController(http.Controller): ] return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}")) + # ── Fallback generators (no OpenAI needed) ── + + @staticmethod + def _fallback_passage(body): + topic = body.get("topic", "travel") + difficulty = body.get("difficulty", "B2") + templates = { + "travel": { + "title": "The Rise of Sustainable Tourism", + "passage": ( + "In recent years, sustainable tourism has emerged as a powerful movement reshaping how " + "people explore the world. Unlike traditional mass tourism, which often prioritises " + "convenience and cost over environmental impact, sustainable tourism encourages travellers " + "to consider their ecological footprint and cultural sensitivity.\n\n" + "The concept gained significant traction after international organisations highlighted the " + "devastating effects of unchecked tourism on fragile ecosystems. Coral reefs in Southeast " + "Asia, ancient ruins in South America, and wildlife reserves in Africa have all suffered " + "from overcrowding, pollution, and habitat destruction caused by the influx of visitors.\n\n" + "Governments and local communities have responded by implementing measures such as visitor " + "caps, eco-certification programmes, and community-based tourism initiatives. In Bhutan, " + "for example, the government charges a daily sustainable development fee to limit tourist " + "numbers while funding conservation efforts and education.\n\n" + "Tour operators have also adapted their business models. Many now offer carbon-offset " + "programmes, partner with local artisans and guides, and design itineraries that minimise " + "environmental disruption. Accommodation providers have invested in solar energy, rainwater " + "harvesting, and waste-reduction systems.\n\n" + "Despite these positive developments, challenges remain. Critics argue that sustainable " + "tourism can be exclusionary, pricing out budget travellers and local residents. Others " + "point out that certification schemes vary widely in rigour and transparency. Nevertheless, " + "the growing awareness among travellers suggests that the industry is moving in the right " + "direction, balancing economic growth with environmental stewardship." + ), + }, + "technology": { + "title": "Artificial Intelligence in Everyday Life", + "passage": ( + "Artificial intelligence, once confined to research laboratories and science fiction, has " + "become an integral part of daily life. From voice-activated assistants on smartphones to " + "recommendation algorithms on streaming platforms, AI systems now influence many of the " + "choices people make without them even realising it.\n\n" + "One of the most visible applications of AI is in healthcare. Machine learning algorithms " + "can now analyse medical images with remarkable accuracy, sometimes identifying conditions " + "such as early-stage cancers that human radiologists might miss. Hospitals around the world " + "are adopting AI-powered tools for patient triage, drug discovery, and personalised " + "treatment planning.\n\n" + "In education, AI-driven platforms adapt learning content to individual student needs. These " + "systems monitor a learner's progress and adjust the difficulty and style of materials " + "accordingly, providing a customised experience that traditional classroom settings often " + "cannot match.\n\n" + "However, the rapid adoption of AI has raised important ethical questions. Issues of data " + "privacy, algorithmic bias, and job displacement have sparked intense debate among " + "policymakers, technologists, and the general public. There are growing calls for " + "regulations that ensure AI systems are transparent, fair, and accountable.\n\n" + "As AI continues to evolve, its impact on society will only deepen. The challenge lies in " + "harnessing its potential for good while mitigating the risks it poses to privacy, " + "employment, and social equity." + ), + }, + } + t = topic.lower().strip() + if t in templates: + return templates[t] + default = templates["travel"] + default["title"] = f"{topic.title()} — A {difficulty} Level Reading Passage" + default["passage"] = default["passage"].replace("sustainable tourism", topic.lower()) + return default + + @staticmethod + def _fallback_writing_instructions(body): + topic = body.get("topic", "general") + difficulty = body.get("difficulty", "B2") + task_type = body.get("task_type", "essay") + templates = { + "essay": ( + f"Write an essay of at least 250 words on the following topic:\n\n" + f"\"{topic.title()}\"\n\n" + "You should:\n" + "• present a clear position on the issue\n" + "• support your arguments with relevant examples\n" + "• organise your ideas logically with clear paragraphing\n" + "• use a range of vocabulary and grammatical structures\n\n" + "Write at least 250 words." + ), + "report": ( + f"The chart/graph below shows information about {topic.lower()}.\n\n" + "Summarise the information by selecting and reporting the main features, " + "and make comparisons where relevant.\n\n" + "Write at least 150 words." + ), + "letter": ( + f"You recently had an experience related to {topic.lower()}. " + "Write a letter to a friend describing what happened.\n\n" + "In your letter:\n" + "• explain the situation\n" + "• describe how you felt\n" + "• suggest what your friend should do in a similar situation\n\n" + "Write at least 150 words. You do NOT need to write any addresses." + ), + } + return {"instructions": templates.get(task_type, templates["essay"])} + + @staticmethod + def _fallback_speaking_script(body): + part = body.get("part", "speaking_1") + topics = body.get("topics", []) + topic_str = ", ".join(t for t in topics if t) or "general conversation" + + scripts = { + "speaking_1": ( + f"Part 1 — Introduction and Interview\n\n" + f"Examiner: Good morning/afternoon. My name is [Examiner]. " + f"Can you tell me your full name, please?\n\n" + f"Now I'd like to ask you some questions about {topic_str}.\n\n" + f"1. Can you tell me about your experience with {topic_str}?\n" + f"2. How important is {topic_str} in your daily life?\n" + f"3. Has your interest in {topic_str} changed over the years?\n" + f"4. What do most people in your country think about {topic_str}?\n" + ), + "speaking_2": ( + f"Part 2 — Individual Long Turn\n\n" + f"Examiner: Now I'm going to give you a topic, and I'd like you to talk " + f"about it for one to two minutes. You have one minute to prepare.\n\n" + f"Describe a time when you experienced something related to {topic_str}.\n\n" + f"You should say:\n" + f"• what happened\n" + f"• when and where it happened\n" + f"• who was involved\n" + f"and explain how you felt about it.\n" + ), + "speaking_3": ( + f"Part 3 — Two-way Discussion\n\n" + f"Examiner: We've been talking about {topic_str}, and now I'd like to " + f"discuss some broader questions related to this topic.\n\n" + f"1. How has {topic_str} changed in your country in recent years?\n" + f"2. Do you think {topic_str} will be more or less important in the future? Why?\n" + f"3. What are the advantages and disadvantages of {topic_str}?\n" + f"4. How might governments address challenges related to {topic_str}?\n" + ), + } + return {"script": scripts.get(part, scripts["speaking_1"])} + + @staticmethod + def _fallback_listening_context(body): + topic = body.get("topic", "everyday life") + section_type = body.get("section_type", "social_conversation") + + transcripts = { + "social_conversation": ( + f"[A conversation between two friends about {topic}]\n\n" + "Speaker A: Hi! I haven't seen you in ages. How have you been?\n\n" + "Speaker B: I've been great, thanks. Actually, I've been quite busy lately " + f"because I've been working on something related to {topic}.\n\n" + "Speaker A: Oh really? That sounds interesting. Tell me more about it.\n\n" + f"Speaker B: Well, it started about three months ago when I decided to " + f"explore {topic} more seriously. I joined a local group and we meet every " + f"Tuesday evening to discuss different aspects of it.\n\n" + "Speaker A: That sounds fantastic. Have you learned a lot?\n\n" + "Speaker B: Absolutely. I've discovered that there's much more to it than " + "I originally thought. For instance, did you know that most experts " + "recommend starting with the basics before moving to advanced topics?\n\n" + "Speaker A: I didn't know that. Maybe I should join your group too.\n\n" + "Speaker B: You'd be very welcome! The next meeting is this Tuesday at " + "seven o'clock in the community centre on Park Road." + ), + "academic_lecture": ( + f"[An academic lecture about {topic}]\n\n" + f"Professor: Good morning, everyone. Today we'll be discussing {topic} " + "and its significance in the modern world.\n\n" + f"As you may already know, research into {topic} has expanded significantly " + "over the past decade. Recent studies have shown that understanding this " + "area can have far-reaching implications for both theory and practice.\n\n" + "Let me begin by outlining the three main approaches that researchers " + "have taken. The first approach focuses on quantitative analysis, " + "using large datasets to identify patterns. The second emphasises " + "qualitative methods, drawing on interviews and case studies. The third, " + "and perhaps most promising, combines both methodologies.\n\n" + "Now, I'd like to draw your attention to a landmark study published " + "in 2023 by Dr. Chen and her colleagues. Their findings suggested that " + "a combined approach yielded results that were 40% more reliable than " + "either method used in isolation." + ), + } + return {"context": transcripts.get(section_type, transcripts["social_conversation"])} + + @staticmethod + def _fallback_exercises(module, body): + exercise_types = body.get("exercise_types", ["mcq"]) + type_counts = body.get("type_counts", {}) + default_count = body.get("count_per_type", 5) + difficulty = body.get("difficulty", "B2") + questions = [] + + q_templates = { + "mcq": lambda i: { + "type": "mcq", + "instructions": "Choose the correct answer for each question.", + "prompt": f"According to the passage, what is the main idea discussed in paragraph {i + 1}?", + "options": [ + "The historical background of the topic", + "The current challenges being faced", + "Future predictions and recommendations", + "A comparison of different approaches", + ], + "correct_answer": "The current challenges being faced", + "explanation": "The paragraph primarily discusses the challenges faced in this area.", + "marks": 1, + }, + "true_false": lambda i: { + "type": "true_false", + "instructions": "Do the following statements agree with the information given in the passage? Write TRUE, FALSE, or NOT GIVEN.", + "prompt": [ + "The author supports the idea that the topic will become more important.", + "Research in this area began more than fifty years ago.", + "All experts agree on the best approach to address this issue.", + "The text mentions several countries where changes have occurred.", + "The writer believes that current measures are sufficient.", + ][i % 5], + "options": ["TRUE", "FALSE", "NOT GIVEN"], + "correct_answer": ["TRUE", "FALSE", "NOT GIVEN", "TRUE", "FALSE"][i % 5], + "explanation": "Based on the information provided in the passage.", + "marks": 1, + }, + "fill_blanks": lambda i: { + "type": "fill_blanks", + "instructions": "Complete the sentences below. Choose NO MORE THAN TWO WORDS from the passage for each answer.", + "prompt": [ + "The main factor contributing to changes in this area is ___.", + "Experts recommend that people should first focus on ___.", + "The study found that combined methods were ___ more effective.", + "Local communities have responded by implementing ___.", + "The primary concern raised by critics is the issue of ___.", + ][i % 5], + "options": [], + "correct_answer": [ + "growing awareness", "basic principles", "significantly", + "new measures", "accessibility", + ][i % 5], + "explanation": "This answer can be found in the relevant paragraph of the passage.", + "marks": 1, + }, + "matching_headings": lambda i: { + "type": "matching_headings", + "instructions": "Choose the correct heading for each paragraph from the list below.", + "prompt": f"Paragraph {i + 1}", + "options": [ + "A. An overview of the current situation", + "B. Historical development", + "C. Future challenges and opportunities", + "D. Government responses", + "E. Expert opinions and analysis", + ], + "correct_answer": ["A", "B", "C", "D", "E"][i % 5], + "explanation": f"Paragraph {i + 1} primarily deals with this topic.", + "marks": 1, + }, + "paragraph_match": lambda i: { + "type": "paragraph_match", + "instructions": "Which paragraph contains the following information?", + "prompt": [ + "a reference to research findings", + "a mention of financial concerns", + "an example from a specific country", + "a description of community initiatives", + "a prediction about the future", + ][i % 5], + "options": ["A", "B", "C", "D", "E"], + "correct_answer": ["C", "D", "B", "A", "E"][i % 5], + "explanation": "This information can be found in the specified paragraph.", + "marks": 1, + }, + } + + for et in (exercise_types or ["mcq"]): + count = int(type_counts.get(et, default_count)) + gen_fn = q_templates.get(et, q_templates["mcq"]) + for i in range(count): + q = gen_fn(i) + q["difficulty"] = difficulty + questions.append(q) + + return {"questions": questions} + + @staticmethod + def _fallback_questions(module, body): + difficulty = body.get("difficulty", "B2") + count = int(body.get("count") or body.get("question_count") or 5) + questions = [] + for i in range(count): + questions.append({ + "type": "mcq", + "prompt": f"Sample {module} question {i + 1} at {difficulty} level. " + "Which of the following best describes the main concept?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correct_answer": "Option A", + "explanation": f"This is a sample question for the {module} module.", + "difficulty": difficulty, + "marks": 1, + }) + return {"questions": questions} + # ── POST /api/exam/generation/submit — create exam from generation page ── - @http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False) def generation_submit(self, **kw): + from odoo.addons.encoach_api.controllers.base import validate_token + user = validate_token() + if not user: + return _json_response({"error": "Authentication required"}, 401) + request.update_env(user=user.id) body = _get_json() try: title = body.get("title", "").strip() @@ -461,6 +922,21 @@ class AIController(http.Controller): label = body.get("label", "") modules = body.get("modules", {}) skip_approval = body.get("skip_approval", False) + exam_mode = body.get("exam_mode", "official") + structure_id = body.get("structure_id") + + first_mod = next(iter(modules.values()), {}) if modules else {} + entity_val = first_mod.get("entity", "none") + entity_id = int(entity_val) if entity_val and entity_val != "none" else False + rubric_raw = first_mod.get("rubricId", "") + rubric_id = False + if rubric_raw and rubric_raw.startswith("rubric-"): + try: + rubric_id = int(rubric_raw.split("-", 1)[1]) + except (ValueError, IndexError): + pass + workflow_val = first_mod.get("approvalWorkflow", "none") + workflow_id = int(workflow_val) if workflow_val and workflow_val != "none" else 0 template_id = False try: @@ -482,35 +958,157 @@ class AIController(http.Controller): except KeyError: return _json_response({"error": "encoach.exam.custom model not available"}, 500) - exam = Exam.sudo().create({ + exam_vals = { "title": title, + "label": label, + "exam_mode": exam_mode, "teacher_id": request.env.user.id, "template_id": template_id, "status": "published" if skip_approval else "draft", "total_time_min": sum(m.get("timer", 0) for m in modules.values()), + "total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()), "randomize_questions": any(m.get("shuffling", False) for m in modules.values()), - }) + "grading_system": first_mod.get("gradingSystem", "ielts"), + "access_type": first_mod.get("accessType", "private"), + "approval_workflow_id": workflow_id, + } + if entity_id: + exam_vals["entity_id"] = entity_id + if rubric_id: + exam_vals["rubric_id"] = rubric_id + if structure_id: + exam_vals["structure_id"] = int(structure_id) - try: - Section = request.env["encoach.exam.custom.section"] - seq = 10 - for mod_key, mod_data in modules.items(): - Section.sudo().create({ - "exam_id": exam.id, - "title": mod_key.capitalize(), - "skill": mod_key, - "time_limit_min": mod_data.get("timer", 0), - "scoring_method": "auto", - "sequence": seq, + exam = Exam.sudo().create(exam_vals) + + Section = request.env["encoach.exam.custom.section"].sudo() + Question = request.env["encoach.question"].sudo() + + CEFR_TO_DIFFICULTY = { + "A1": "easy", "A2": "easy", + "B1": "medium", "B2": "medium", + "C1": "hard", "C2": "hard", + } + QUESTION_TYPE_MAP = { + "mcq": "mcq", "true_false": "tfng", "fill_blanks": "gap_fill", + "matching_headings": "heading_matching", "paragraph_match": "matching", + "short_answer": "short_answer", "summary_completion": "summary_completion", + "multiple_choice": "mcq", "sentence_completion": "gap_fill", + "matching_information": "matching", "note_completion": "note_completion", + "form_completion": "form_completion", "map_labelling": "map_labelling", + } + + seq = 10 + total_questions = 0 + for mod_key, mod_data in modules.items(): + difficulty_list = mod_data.get("difficulty", ["B2"]) + cefr_level = difficulty_list[0] if isinstance(difficulty_list, list) and difficulty_list else "B2" + q_difficulty = CEFR_TO_DIFFICULTY.get(cefr_level, "medium") + + section = Section.create({ + "exam_id": exam.id, + "title": mod_key.capitalize(), + "skill": mod_key, + "difficulty": cefr_level, + "time_limit_min": mod_data.get("timer", 0), + "total_marks": float(mod_data.get("totalMarks", 0)), + "scoring_method": "rubric" if mod_key in ("writing", "speaking") else "auto", + "sequence": seq, + "content_json": json.dumps({ + k: mod_data[k] for k in ("passages", "sections", "tasks", "parts") + if k in mod_data and mod_data[k] + }), + }) + seq += 10 + + question_ids = [] + + passages = mod_data.get("passages") or [] + for p_idx, passage in enumerate(passages): + if passage.get("text"): + section.sudo().write({"passage_text": passage["text"]}) + for ex in (passage.get("exercises") or []): + q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") + opts = ex.get("options", []) + q = Question.create({ + "skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading", + "source_type": "passage", + "question_type": q_type, + "stem": ex.get("prompt", "") or ex.get("instructions", ""), + "options": json.dumps(opts) if opts else "[]", + "correct_answer": ex.get("correct_answer", ""), + "marks": float(ex.get("marks", 1)), + "difficulty": q_difficulty, + "status": "active", + "ai_generated": True, + }) + question_ids.append(q.id) + + sections_data = mod_data.get("sections") or [] + for s_data in sections_data: + if s_data.get("context"): + section.sudo().write({"passage_text": s_data["context"]}) + for ex in (s_data.get("exercises") or []): + q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") + opts = ex.get("options", []) + q = Question.create({ + "skill": "listening", + "source_type": "audio", + "question_type": q_type, + "stem": ex.get("prompt", "") or ex.get("instructions", ""), + "options": json.dumps(opts) if opts else "[]", + "correct_answer": ex.get("correct_answer", ""), + "marks": float(ex.get("marks", 1)), + "difficulty": q_difficulty, + "status": "active", + "ai_generated": True, + }) + question_ids.append(q.id) + + tasks = mod_data.get("tasks") or [] + for t_idx, task in enumerate(tasks): + q = Question.create({ + "skill": "writing", + "source_type": "writing_prompt", + "question_type": "short_answer", + "stem": task.get("instructions", f"Writing Task {t_idx + 1}"), + "options": "[]", + "correct_answer": "", + "marks": float(task.get("marks", 0)), + "difficulty": q_difficulty, + "status": "active", + "ai_generated": True, }) - seq += 10 - except KeyError: - pass + question_ids.append(q.id) + + parts = mod_data.get("parts") or [] + for p_idx, part in enumerate(parts): + q = Question.create({ + "skill": "speaking", + "source_type": "speaking_card", + "question_type": "short_answer", + "stem": part.get("script", f"Speaking Part {p_idx + 1}"), + "options": "[]", + "correct_answer": "", + "marks": float(part.get("marks", 0)), + "difficulty": q_difficulty, + "status": "active", + "ai_generated": True, + }) + question_ids.append(q.id) + + if question_ids: + section.sudo().write({ + "question_ids": [(6, 0, question_ids)], + "question_count": len(question_ids), + }) + total_questions += len(question_ids) return _json_response({ "exam_id": exam.id, "status": exam.status, "template_id": template_id, + "total_questions": total_questions, }, 201) except Exception as e: _logger.exception("generation submit failed") @@ -532,8 +1130,13 @@ class AIController(http.Controller): return _json_response({"applied": 0, "error": str(e)}, 500) # ── POST /api/exam//generate/save — save generated exam items ── - @http.route("/api/exam//generate/save", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam//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 diff --git a/backend/custom_addons/encoach_ai/models/__init__.py b/backend/custom_addons/encoach_ai/models/__init__.py index 69ddeed0..25499608 100644 --- a/backend/custom_addons/encoach_ai/models/__init__.py +++ b/backend/custom_addons/encoach_ai/models/__init__.py @@ -1,2 +1,3 @@ from . import ai_settings from . import ai_log +from . import constants diff --git a/backend/custom_addons/encoach_ai/models/constants.py b/backend/custom_addons/encoach_ai/models/constants.py new file mode 100644 index 00000000..75004c66 --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/constants.py @@ -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', +] diff --git a/backend/custom_addons/encoach_ai/services/openai_service.py b/backend/custom_addons/encoach_ai/services/openai_service.py index 622885c7..728fa1ef 100644 --- a/backend/custom_addons/encoach_ai/services/openai_service.py +++ b/backend/custom_addons/encoach_ai/services/openai_service.py @@ -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 diff --git a/backend/custom_addons/encoach_core/data/encoach_permissions.xml b/backend/custom_addons/encoach_core/data/encoach_permissions.xml index 3986610a..7314737a 100644 --- a/backend/custom_addons/encoach_core/data/encoach_permissions.xml +++ b/backend/custom_addons/encoach_core/data/encoach_permissions.xml @@ -1,6 +1,6 @@ - + viewCorporate diff --git a/backend/custom_addons/encoach_exam_template/__manifest__.py b/backend/custom_addons/encoach_exam_template/__manifest__.py index c57cf063..8d9dbeb9 100644 --- a/backend/custom_addons/encoach_exam_template/__manifest__.py +++ b/backend/custom_addons/encoach_exam_template/__manifest__.py @@ -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', diff --git a/backend/custom_addons/encoach_exam_template/controllers/__init__.py b/backend/custom_addons/encoach_exam_template/controllers/__init__.py index a1995d3b..307e74f3 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/__init__.py +++ b/backend/custom_addons/encoach_exam_template/controllers/__init__.py @@ -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 diff --git a/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py b/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py new file mode 100644 index 00000000..d891ab3e --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py @@ -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/', 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//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//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) diff --git a/backend/custom_addons/encoach_exam_template/controllers/entities.py b/backend/custom_addons/encoach_exam_template/controllers/entities.py new file mode 100644 index 00000000..254e98aa --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/entities.py @@ -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) diff --git a/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py b/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py new file mode 100644 index 00000000..42be7d20 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py @@ -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/', 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/', 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//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) diff --git a/backend/custom_addons/encoach_exam_template/controllers/exam_session.py b/backend/custom_addons/encoach_exam_template/controllers/exam_session.py new file mode 100644 index 00000000..45d9c77b --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/controllers/exam_session.py @@ -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//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//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//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//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//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 [] diff --git a/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py b/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py index c94710da..ee53e3bf 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py +++ b/backend/custom_addons/encoach_exam_template/controllers/exam_structures.py @@ -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/', 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/', 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/', 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}) diff --git a/backend/custom_addons/encoach_exam_template/controllers/rubrics.py b/backend/custom_addons/encoach_exam_template/controllers/rubrics.py index ae9ba783..370497d7 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/rubrics.py +++ b/backend/custom_addons/encoach_exam_template/controllers/rubrics.py @@ -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/', 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/', 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/', 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/', 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) diff --git a/backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml b/backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml new file mode 100644 index 00000000..090757c5 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/data/ir_cron_schedule.xml @@ -0,0 +1,14 @@ + + + + + Exam Schedule Lifecycle + + code + model.update_lifecycle_states() + 1 + minutes + True + + + diff --git a/backend/custom_addons/encoach_exam_template/models/__init__.py b/backend/custom_addons/encoach_exam_template/models/__init__.py index 0479cdc5..8cff707e 100644 --- a/backend/custom_addons/encoach_exam_template/models/__init__.py +++ b/backend/custom_addons/encoach_exam_template/models/__init__.py @@ -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 diff --git a/backend/custom_addons/encoach_exam_template/models/exam_assignment.py b/backend/custom_addons/encoach_exam_template/models/exam_assignment.py index a2fc40cd..bd105492 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_assignment.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_assignment.py @@ -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() diff --git a/backend/custom_addons/encoach_exam_template/models/exam_custom.py b/backend/custom_addons/encoach_exam_template/models/exam_custom.py index 7efc2596..f0b86232 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_custom.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_custom.py @@ -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'), diff --git a/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py b/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py index 34836752..e74c7074 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_custom_section.py @@ -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', diff --git a/backend/custom_addons/encoach_exam_template/models/exam_schedule.py b/backend/custom_addons/encoach_exam_template/models/exam_schedule.py new file mode 100644 index 00000000..bceb95a2 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/models/exam_schedule.py @@ -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'}) diff --git a/backend/custom_addons/encoach_exam_template/models/rubric.py b/backend/custom_addons/encoach_exam_template/models/rubric.py index 6a38ddf5..ad506b00 100644 --- a/backend/custom_addons/encoach_exam_template/models/rubric.py +++ b/backend/custom_addons/encoach_exam_template/models/rubric.py @@ -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'), diff --git a/backend/custom_addons/encoach_exam_template/models/rubric_group.py b/backend/custom_addons/encoach_exam_template/models/rubric_group.py new file mode 100644 index 00000000..fa024357 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/models/rubric_group.py @@ -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', + ) diff --git a/backend/custom_addons/encoach_exam_template/models/student_attempt.py b/backend/custom_addons/encoach_exam_template/models/student_attempt.py new file mode 100644 index 00000000..990676e9 --- /dev/null +++ b/backend/custom_addons/encoach_exam_template/models/student_attempt.py @@ -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') diff --git a/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv b/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv index b38960e9..98aa75a9 100644 --- a/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_exam_template/security/ir.model.access.csv @@ -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 diff --git a/backend/custom_addons/encoach_scoring/controllers/exam_session.py b/backend/custom_addons/encoach_scoring/controllers/exam_session.py index 7466c941..1d5b3025 100644 --- a/backend/custom_addons/encoach_scoring/controllers/exam_session.py +++ b/backend/custom_addons/encoach_scoring/controllers/exam_session.py @@ -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, diff --git a/frontend/src/components/StudentLayout.tsx b/frontend/src/components/StudentLayout.tsx index 3fc64e30..9fca00fd 100644 --- a/frontend/src/components/StudentLayout.tsx +++ b/frontend/src/components/StudentLayout.tsx @@ -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 ; + return ( + <> + + + + ); } diff --git a/frontend/src/components/student/ExamPopup.tsx b/frontend/src/components/student/ExamPopup.tsx new file mode 100644 index 00000000..5fc037f1 --- /dev/null +++ b/frontend/src/components/student/ExamPopup.tsx @@ -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>(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 ( + + + + + + Upcoming Exams ({pendingExams.length}) + + +
+ {pendingExams.map((exam) => ( +
+
+

{exam.exam_title || exam.schedule_name}

+ + {exam.schedule_state === "active" ? "Active Now" : exam.schedule_state} + +
+ + {exam.schedule_name && exam.exam_title && ( +

{exam.schedule_name}

+ )} + +
+ + + From: {formatDate(exam.start_date)} + + + + To: {formatDate(exam.end_date)} + +
+ +
+ + + {!exam.can_start && ( + + {exam.schedule_state === "planned" + ? "Exam will be available once it becomes active" + : "Exam is not currently active"} + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/pages/ApprovalWorkflowsPage.tsx b/frontend/src/pages/ApprovalWorkflowsPage.tsx index f722e9a2..f2e878b9 100644 --- a/frontend/src/pages/ApprovalWorkflowsPage.tsx +++ b/frontend/src/pages/ApprovalWorkflowsPage.tsx @@ -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 ; - if (s === "Rejected") return ; - return ; + if (s === "approved") return ; + if (s === "rejected") return ; + return ; +}; + +const stateBadge = (state: string) => { + const map: Record = { + approved: "default", + in_progress: "secondary", + rejected: "destructive", + draft: "outline", + }; + return ( + + {state.replace(/_/g, " ")} + + ); }; 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(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) => 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 (
+ {/* Header */}

Approval Workflows

-

Manage multi-step approval processes for exam content.

+

+ Manage multi-step approval processes for exam content. +

- + { + setCreateOpen(open); + if (!open) resetForm(); + }} + > - + - Create Workflow + + Create Workflow + Define a multi-step approval process. +
-
- - + + setFormName(e.target.value)} + placeholder="e.g. Exam Content Review" + />
- - + + + + + Custom + Exam Publication + Assignment Publication + Content Publication +
- + + {formSteps.map((step, i) => ( +
+
+ + {formSteps.length > 1 && ( + + )} +
+ +
+ ))} + +
+ + + +
-
+ {/* Loading */} + {(workflowsQ.isLoading || requestsQ.isLoading) && ( +
+ +
+ )} + + {/* Pending Requests */} + {pendingRequests.length > 0 && ( +
+

+ + Pending Approval ({pendingRequests.length}) +

+ {pendingRequests.map((req) => { + const wf = workflows.find((w) => w.id === req.workflow_id); + return ( + + +
+
+

{req.workflow_name}

+

+ Requested by {req.requester_name} ·{" "} + {req.res_model.replace("encoach.", "")} #{req.res_id} +

+
+ {stateBadge(req.state)} +
+ + {wf && ( +
+ {wf.steps.map((step, i) => { + const isActive = step.id === req.current_stage_id; + return ( +
+
+ {statusIcon(step.status)} +
+

Step {i + 1}

+

+ {step.approver_name} +

+
+
+ {i < wf.steps.length - 1 && ( + + )} +
+ ); + })} +
+ )} + +
+ + +
+
+
+ ); + })} +
+ )} + + {/* Workflow Templates */} +
+

+ + Workflow Templates ({workflows.length}) +

+ {workflows.length === 0 && !workflowsQ.isLoading && ( + + + No workflows defined yet. Create one to get started. + + + )} {workflows.map((wf) => (
- {wf.name} - {wf.status} +
+ {wf.name} + + {wf.type.replace(/_/g, " ")} + +
+
-
+
{wf.steps.map((step, i) => ( -
+
- {statusIcon(step.status)} +
+ {i + 1} +
-

{step.name}

-

{step.assignee}

+

{step.approver_name}

+

+ {step.max_days}d limit + {step.auto_escalate ? " · auto-escalate" : ""} +

- {i < wf.steps.length - 1 &&
} + {i < wf.steps.length - 1 && ( + + )}
))}
- {wf.status === "In Progress" && ( -
- { - toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` }); - }} /> -
- - -
-
- )} ))}
+ + {/* Completed Requests */} + {completedRequests.length > 0 && ( +
+

+ + Recent Decisions ({completedRequests.length}) +

+ {completedRequests.map((req) => ( + + +
+

{req.workflow_name}

+

+ {req.requester_name} · {req.res_model.replace("encoach.", "")} # + {req.res_id} + {req.created_at && ( + <> + {" "} + · {new Date(req.created_at).toLocaleDateString()} + + )} +

+
+ {stateBadge(req.state)} +
+
+ ))} +
+ )} + + {/* Reject Dialog */} + { + if (!open) { + setRejectDialog(null); + setRejectComment(""); + } + }} + > + + + Reject Request + Provide a reason for rejection. + +
+ +