feat: complete exam lifecycle — AI generation, submission, student session, and results
- Backend: AI generation fallbacks when OpenAI not configured, full exam submission saving all params (difficulty, rubric, entity, grading system, approval workflow) and creating linked question records per section - Backend: new exam session controller with get_session, autosave, submit, status, and results endpoints; student attempt/answer/score models - Backend: new controllers for entities, approval workflows, exam schedules - Frontend: exam session split-layout with passage panel, question types (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog - Frontend: results page with percentage score, per-answer breakdown table - Frontend: generation page dynamic dropdowns, full payload submission - Frontend: updated types for ExamSessionSection, ExamQuestion options Made-with: Cursor
This commit is contained in:
@@ -314,38 +314,194 @@ class AIController(http.Controller):
|
|||||||
_logger.exception("workbench publish failed")
|
_logger.exception("workbench publish failed")
|
||||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
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 ──
|
# ── Exam generation — GenerationPage.tsx ──
|
||||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
@http.route("/api/exam/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
def exam_generate(self, module, **kw):
|
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()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env)
|
||||||
|
has_ai = bool(ai.client)
|
||||||
|
except Exception:
|
||||||
|
ai, has_ai = None, False
|
||||||
|
|
||||||
|
try:
|
||||||
if body.get("generate_passage"):
|
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"):
|
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"):
|
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"):
|
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"):
|
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")
|
difficulty = body.get("difficulty", "B2")
|
||||||
topic = body.get("topic", "")
|
topic = body.get("topic", "")
|
||||||
count = body.get("count") or body.get("question_count") or 5
|
count = body.get("count") or body.get("question_count") or 5
|
||||||
messages = [
|
if has_ai:
|
||||||
{"role": "system", "content": (
|
messages = [
|
||||||
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
{"role": "system", "content": (
|
||||||
f"Return JSON: "
|
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
||||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
f"Return JSON: "
|
||||||
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
|
'{"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})},
|
)},
|
||||||
]
|
{"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(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
||||||
|
return _json_response(self._fallback_questions(module, body))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception("exam_generate %s failed: %s", module, e)
|
_logger.exception("exam_generate %s failed: %s", module, e)
|
||||||
return _json_response({"questions": [], "error": str(e)}, 500)
|
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}"))
|
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 ──
|
# ── 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):
|
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()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
title = body.get("title", "").strip()
|
title = body.get("title", "").strip()
|
||||||
@@ -461,6 +922,21 @@ class AIController(http.Controller):
|
|||||||
label = body.get("label", "")
|
label = body.get("label", "")
|
||||||
modules = body.get("modules", {})
|
modules = body.get("modules", {})
|
||||||
skip_approval = body.get("skip_approval", False)
|
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
|
template_id = False
|
||||||
try:
|
try:
|
||||||
@@ -482,35 +958,157 @@ class AIController(http.Controller):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||||
|
|
||||||
exam = Exam.sudo().create({
|
exam_vals = {
|
||||||
"title": title,
|
"title": title,
|
||||||
|
"label": label,
|
||||||
|
"exam_mode": exam_mode,
|
||||||
"teacher_id": request.env.user.id,
|
"teacher_id": request.env.user.id,
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
"status": "published" if skip_approval else "draft",
|
"status": "published" if skip_approval else "draft",
|
||||||
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
|
"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()),
|
"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:
|
exam = Exam.sudo().create(exam_vals)
|
||||||
Section = request.env["encoach.exam.custom.section"]
|
|
||||||
seq = 10
|
Section = request.env["encoach.exam.custom.section"].sudo()
|
||||||
for mod_key, mod_data in modules.items():
|
Question = request.env["encoach.question"].sudo()
|
||||||
Section.sudo().create({
|
|
||||||
"exam_id": exam.id,
|
CEFR_TO_DIFFICULTY = {
|
||||||
"title": mod_key.capitalize(),
|
"A1": "easy", "A2": "easy",
|
||||||
"skill": mod_key,
|
"B1": "medium", "B2": "medium",
|
||||||
"time_limit_min": mod_data.get("timer", 0),
|
"C1": "hard", "C2": "hard",
|
||||||
"scoring_method": "auto",
|
}
|
||||||
"sequence": seq,
|
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
|
question_ids.append(q.id)
|
||||||
except KeyError:
|
|
||||||
pass
|
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({
|
return _json_response({
|
||||||
"exam_id": exam.id,
|
"exam_id": exam.id,
|
||||||
"status": exam.status,
|
"status": exam.status,
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
|
"total_questions": total_questions,
|
||||||
}, 201)
|
}, 201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception("generation submit failed")
|
_logger.exception("generation submit failed")
|
||||||
@@ -532,8 +1130,13 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||||
|
|
||||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
||||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
|
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
def exam_generate_save(self, module, **kw):
|
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()
|
body = _get_json()
|
||||||
questions = body.get("questions", [])
|
questions = body.get("questions", [])
|
||||||
saved = 0
|
saved = 0
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import ai_settings
|
from . import ai_settings
|
||||||
from . import ai_log
|
from . import ai_log
|
||||||
|
from . import constants
|
||||||
|
|||||||
13
backend/custom_addons/encoach_ai/models/constants.py
Normal file
13
backend/custom_addons/encoach_ai/models/constants.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CEFR_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
|
||||||
|
|
||||||
|
GPT_MODELS = {
|
||||||
|
'default': 'gpt-4o',
|
||||||
|
'fast': 'gpt-4o-mini',
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMPERATURE = 0.7
|
||||||
|
|
||||||
|
TOPICS = [
|
||||||
|
'Education', 'Technology', 'Health', 'Environment', 'Culture',
|
||||||
|
'Travel', 'Science', 'Business', 'Sports', 'Society',
|
||||||
|
]
|
||||||
@@ -341,3 +341,6 @@ class OpenAIService:
|
|||||||
messages.append({"role": "user", "content": brief_text})
|
messages.append({"role": "user", "content": brief_text})
|
||||||
|
|
||||||
return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096)
|
return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096)
|
||||||
|
|
||||||
|
|
||||||
|
EncoachOpenAIService = OpenAIService
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<data noupdate="0">
|
<data noupdate="1">
|
||||||
<!-- Global permissions (PermissionType from permissions.ts) -->
|
<!-- Global permissions (PermissionType from permissions.ts) -->
|
||||||
<record id="perm_viewCorporate" model="encoach.permission">
|
<record id="perm_viewCorporate" model="encoach.permission">
|
||||||
<field name="code">viewCorporate</field>
|
<field name="code">viewCorporate</field>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
'data/ielts_templates.xml',
|
'data/ielts_templates.xml',
|
||||||
'data/sample_passages.xml',
|
'data/sample_passages.xml',
|
||||||
'data/sample_questions.xml',
|
'data/sample_questions.xml',
|
||||||
|
'data/ir_cron_schedule.xml',
|
||||||
'views/exam_template_views.xml',
|
'views/exam_template_views.xml',
|
||||||
'views/passage_views.xml',
|
'views/passage_views.xml',
|
||||||
'views/audio_file_views.xml',
|
'views/audio_file_views.xml',
|
||||||
|
|||||||
@@ -3,4 +3,8 @@ from . import ielts_exam
|
|||||||
from . import custom_exam
|
from . import custom_exam
|
||||||
from . import exam_structures
|
from . import exam_structures
|
||||||
from . import assignments
|
from . import assignments
|
||||||
|
from . import exam_schedules
|
||||||
from . import rubrics
|
from . import rubrics
|
||||||
|
from . import approval_workflows
|
||||||
|
from . import entities
|
||||||
|
from . import exam_session
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response, _get_json_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _cr():
|
||||||
|
return request.env.cr
|
||||||
|
|
||||||
|
|
||||||
|
def _user_name(cr, user_id):
|
||||||
|
if not user_id:
|
||||||
|
return ''
|
||||||
|
cr.execute(
|
||||||
|
"SELECT rp.name FROM res_users ru "
|
||||||
|
"JOIN res_partner rp ON rp.id = ru.partner_id "
|
||||||
|
"WHERE ru.id = %s", (user_id,))
|
||||||
|
row = cr.fetchone()
|
||||||
|
return row[0] if row else ''
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalWorkflowController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/approval-workflows', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_workflows(self, **kw):
|
||||||
|
try:
|
||||||
|
cr = _cr()
|
||||||
|
cr.execute(
|
||||||
|
"SELECT id, name, type, allow_bypass, entity_id, create_date "
|
||||||
|
"FROM encoach_approval_workflow ORDER BY create_date DESC")
|
||||||
|
workflows = cr.fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for wf_id, name, wtype, bypass, entity_id, created in workflows:
|
||||||
|
cr.execute(
|
||||||
|
"SELECT id, sequence, approver_id, status, comment, "
|
||||||
|
"auto_escalate, max_days, acted_at "
|
||||||
|
"FROM encoach_approval_stage WHERE workflow_id = %s "
|
||||||
|
"ORDER BY sequence ASC", (wf_id,))
|
||||||
|
steps = []
|
||||||
|
for sid, seq, approver_id, status, comment, escalate, max_d, acted in cr.fetchall():
|
||||||
|
steps.append({
|
||||||
|
'id': sid,
|
||||||
|
'sequence': seq,
|
||||||
|
'approver_id': approver_id,
|
||||||
|
'approver_name': _user_name(cr, approver_id),
|
||||||
|
'status': status or 'pending',
|
||||||
|
'comment': comment or '',
|
||||||
|
'auto_escalate': bool(escalate),
|
||||||
|
'max_days': max_d or 0,
|
||||||
|
'acted_at': acted.isoformat() if acted else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
entity_name = None
|
||||||
|
if entity_id:
|
||||||
|
cr.execute("SELECT name FROM encoach_entity WHERE id=%s", (entity_id,))
|
||||||
|
row = cr.fetchone()
|
||||||
|
entity_name = row[0] if row else None
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'id': wf_id,
|
||||||
|
'name': name,
|
||||||
|
'type': wtype or '',
|
||||||
|
'entity_id': entity_id,
|
||||||
|
'entity_name': entity_name,
|
||||||
|
'allow_bypass': bool(bypass),
|
||||||
|
'steps': steps,
|
||||||
|
'created': created.strftime('%Y-%m-%d') if created else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'items': items, 'total': len(items)})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('list workflows failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/approval-workflows', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create_workflow(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
name = body.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return _error_response('name is required', 400)
|
||||||
|
|
||||||
|
cr = _cr()
|
||||||
|
now = datetime.now()
|
||||||
|
uid = request.env.uid
|
||||||
|
|
||||||
|
cr.execute(
|
||||||
|
"INSERT INTO encoach_approval_workflow "
|
||||||
|
"(name, type, allow_bypass, create_uid, write_uid, create_date, write_date) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||||
|
(name, body.get('type', 'custom'), body.get('allow_bypass', False),
|
||||||
|
uid, uid, now, now))
|
||||||
|
wf_id = cr.fetchone()[0]
|
||||||
|
|
||||||
|
for i, step in enumerate(body.get('steps', [])):
|
||||||
|
approver_id = step.get('approver_id')
|
||||||
|
if approver_id:
|
||||||
|
cr.execute(
|
||||||
|
"INSERT INTO encoach_approval_stage "
|
||||||
|
"(workflow_id, sequence, approver_id, status, auto_escalate, max_days, "
|
||||||
|
"create_uid, write_uid, create_date, write_date) "
|
||||||
|
"VALUES (%s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s)",
|
||||||
|
(wf_id, (i + 1) * 10, int(approver_id),
|
||||||
|
step.get('auto_escalate', True), step.get('max_days', 3),
|
||||||
|
uid, uid, now, now))
|
||||||
|
|
||||||
|
cr.execute("SELECT id, name, type FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
|
||||||
|
row = cr.fetchone()
|
||||||
|
return _json_response({'id': row[0], 'name': row[1], 'type': row[2]}, 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('create workflow failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/approval-workflows/<int:wf_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def delete_workflow(self, wf_id, **kw):
|
||||||
|
try:
|
||||||
|
cr = _cr()
|
||||||
|
cr.execute("DELETE FROM encoach_approval_request WHERE workflow_id=%s", (wf_id,))
|
||||||
|
cr.execute("DELETE FROM encoach_approval_stage WHERE workflow_id=%s", (wf_id,))
|
||||||
|
cr.execute("DELETE FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
|
||||||
|
return _json_response({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('delete workflow failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
# ── Approval Requests ──
|
||||||
|
|
||||||
|
@http.route('/api/approval-requests', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_requests(self, **kw):
|
||||||
|
try:
|
||||||
|
cr = _cr()
|
||||||
|
state_filter = kw.get('state')
|
||||||
|
where = ""
|
||||||
|
params = []
|
||||||
|
if state_filter:
|
||||||
|
where = "WHERE ar.state = %s"
|
||||||
|
params.append(state_filter)
|
||||||
|
|
||||||
|
cr.execute(f"""
|
||||||
|
SELECT ar.id, ar.workflow_id, aw.name as wf_name,
|
||||||
|
ar.res_model, ar.res_id, ar.state,
|
||||||
|
ar.requester_id, ar.current_stage_id,
|
||||||
|
ar.bypass_reason, ar.created_at
|
||||||
|
FROM encoach_approval_request ar
|
||||||
|
LEFT JOIN encoach_approval_workflow aw ON aw.id = ar.workflow_id
|
||||||
|
{where}
|
||||||
|
ORDER BY ar.created_at DESC NULLS LAST
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in cr.fetchall():
|
||||||
|
rid, wf_id, wf_name, model, res_id, state, req_id, stage_id, bypass, created = row
|
||||||
|
stage_seq = None
|
||||||
|
if stage_id:
|
||||||
|
cr.execute("SELECT sequence FROM encoach_approval_stage WHERE id=%s", (stage_id,))
|
||||||
|
s = cr.fetchone()
|
||||||
|
stage_seq = s[0] if s else None
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'id': rid,
|
||||||
|
'workflow_id': wf_id,
|
||||||
|
'workflow_name': wf_name or '',
|
||||||
|
'res_model': model or '',
|
||||||
|
'res_id': res_id or 0,
|
||||||
|
'state': state or 'draft',
|
||||||
|
'requester_id': req_id,
|
||||||
|
'requester_name': _user_name(cr, req_id),
|
||||||
|
'current_stage_id': stage_id,
|
||||||
|
'current_stage_sequence': stage_seq,
|
||||||
|
'bypass_reason': bypass or '',
|
||||||
|
'created_at': created.isoformat() if created else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({'items': items, 'total': len(items)})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('list requests failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/approval-requests/<int:req_id>/approve', type='http',
|
||||||
|
auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def approve_request(self, req_id, **kw):
|
||||||
|
try:
|
||||||
|
cr = _cr()
|
||||||
|
body = _get_json_body()
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
cr.execute(
|
||||||
|
"SELECT workflow_id, current_stage_id FROM encoach_approval_request WHERE id=%s",
|
||||||
|
(req_id,))
|
||||||
|
row = cr.fetchone()
|
||||||
|
if not row:
|
||||||
|
return _error_response('Request not found', 404)
|
||||||
|
wf_id, stage_id = row
|
||||||
|
|
||||||
|
if stage_id:
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE encoach_approval_stage SET status='approved', comment=%s, acted_at=%s "
|
||||||
|
"WHERE id=%s", (body.get('comment', ''), now, stage_id))
|
||||||
|
|
||||||
|
cr.execute(
|
||||||
|
"SELECT id FROM encoach_approval_stage WHERE workflow_id=%s ORDER BY sequence ASC",
|
||||||
|
(wf_id,))
|
||||||
|
all_stages = [r[0] for r in cr.fetchall()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
idx = all_stages.index(stage_id)
|
||||||
|
except ValueError:
|
||||||
|
idx = -1
|
||||||
|
|
||||||
|
if idx + 1 < len(all_stages):
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE encoach_approval_request SET current_stage_id=%s, state='in_progress' "
|
||||||
|
"WHERE id=%s", (all_stages[idx + 1], req_id))
|
||||||
|
else:
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE encoach_approval_request SET state='approved' WHERE id=%s", (req_id,))
|
||||||
|
|
||||||
|
return _json_response({'success': True, 'id': req_id})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('approve request failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/approval-requests/<int:req_id>/reject', type='http',
|
||||||
|
auth='none', methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def reject_request(self, req_id, **kw):
|
||||||
|
try:
|
||||||
|
cr = _cr()
|
||||||
|
body = _get_json_body()
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
cr.execute("SELECT current_stage_id FROM encoach_approval_request WHERE id=%s", (req_id,))
|
||||||
|
row = cr.fetchone()
|
||||||
|
if not row:
|
||||||
|
return _error_response('Request not found', 404)
|
||||||
|
|
||||||
|
stage_id = row[0]
|
||||||
|
if stage_id:
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE encoach_approval_stage SET status='rejected', comment=%s, acted_at=%s "
|
||||||
|
"WHERE id=%s", (body.get('comment', ''), now, stage_id))
|
||||||
|
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE encoach_approval_request SET state='rejected' WHERE id=%s", (req_id,))
|
||||||
|
return _json_response({'success': True, 'id': req_id})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('reject request failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
# ── Users for assignee selection ──
|
||||||
|
|
||||||
|
@http.route('/api/approval-users', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_users(self, **kw):
|
||||||
|
try:
|
||||||
|
cr = _cr()
|
||||||
|
cr.execute(
|
||||||
|
"SELECT ru.id, rp.name, ru.login "
|
||||||
|
"FROM res_users ru JOIN res_partner rp ON rp.id = ru.partner_id "
|
||||||
|
"WHERE ru.active = true AND ru.id > 1 "
|
||||||
|
"ORDER BY rp.name LIMIT 100")
|
||||||
|
items = [{'id': r[0], 'name': r[1], 'login': r[2]} for r in cr.fetchall()]
|
||||||
|
return _json_response({'items': items})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('list users failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EntityController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/entities', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_entities(self, **kw):
|
||||||
|
try:
|
||||||
|
cr = request.env.cr
|
||||||
|
cr.execute(
|
||||||
|
"SELECT id, name, code, type FROM encoach_entity ORDER BY name")
|
||||||
|
items = []
|
||||||
|
for eid, name, code, etype in cr.fetchall():
|
||||||
|
items.append({
|
||||||
|
'id': eid,
|
||||||
|
'name': name,
|
||||||
|
'code': code or '',
|
||||||
|
'type': etype or '',
|
||||||
|
})
|
||||||
|
return _json_response({'items': items, 'total': len(items)})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('list entities failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
validate_token, _json_response as _base_json_response, _error_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_response(data, status=200):
|
||||||
|
return request.make_json_response(data, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_jwt():
|
||||||
|
"""Validate JWT and set the user context. Return user or error response."""
|
||||||
|
user = validate_token()
|
||||||
|
if not user:
|
||||||
|
return None, _error_response("Authentication required", status=401)
|
||||||
|
request.update_env(user=user.id)
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_dt(val):
|
||||||
|
"""Convert ISO datetime strings (with T) to Odoo-compatible format."""
|
||||||
|
if not val or not isinstance(val, str):
|
||||||
|
return val
|
||||||
|
return val.replace('T', ' ').replace('Z', '').split('+')[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_to_dict(rec):
|
||||||
|
return {
|
||||||
|
'id': rec.id,
|
||||||
|
'name': rec.name,
|
||||||
|
'exam_id': rec.exam_id.id if rec.exam_id else None,
|
||||||
|
'exam_title': rec.exam_id.title if rec.exam_id else '',
|
||||||
|
'entity_id': rec.entity_id.id if rec.entity_id else None,
|
||||||
|
'entity_name': rec.entity_id.name if rec.entity_id else '',
|
||||||
|
'start_date': rec.start_date.isoformat() if rec.start_date else None,
|
||||||
|
'end_date': rec.end_date.isoformat() if rec.end_date else None,
|
||||||
|
'state': rec.state,
|
||||||
|
'assign_mode': rec.assign_mode,
|
||||||
|
'full_length': rec.full_length,
|
||||||
|
'generate_different': rec.generate_different,
|
||||||
|
'auto_release_results': rec.auto_release_results,
|
||||||
|
'auto_start': rec.auto_start,
|
||||||
|
'official_exam': rec.official_exam,
|
||||||
|
'hide_assignee_details': rec.hide_assignee_details,
|
||||||
|
'batch_ids': rec.batch_ids.ids,
|
||||||
|
'batch_names': [b.name for b in rec.batch_ids],
|
||||||
|
'student_ids': rec.student_ids.ids,
|
||||||
|
'assignee_count': rec.assignee_count,
|
||||||
|
'completed_count': rec.completed_count,
|
||||||
|
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachExamScheduleController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/exam-schedules', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
def list_schedules(self, **kw):
|
||||||
|
try:
|
||||||
|
user, err = _require_jwt()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
Schedule = request.env['encoach.exam.schedule'].sudo()
|
||||||
|
domain = []
|
||||||
|
state_filter = kw.get('state')
|
||||||
|
if state_filter:
|
||||||
|
domain.append(('state', '=', state_filter))
|
||||||
|
|
||||||
|
limit = int(kw.get('limit', 50))
|
||||||
|
offset = int(kw.get('offset', 0))
|
||||||
|
total = Schedule.search_count(domain)
|
||||||
|
records = Schedule.search(domain, limit=limit, offset=offset,
|
||||||
|
order='start_date desc')
|
||||||
|
return _json_response({
|
||||||
|
'items': [_schedule_to_dict(r) for r in records],
|
||||||
|
'total': total,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('exam-schedules list failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam-schedules', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
def create_schedule(self, **kw):
|
||||||
|
try:
|
||||||
|
user, err = _require_jwt()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
body = json.loads(request.httprequest.data or '{}')
|
||||||
|
name = body.get('name', '').strip()
|
||||||
|
exam_id = body.get('exam_id')
|
||||||
|
if not name or not exam_id:
|
||||||
|
return _json_response({'error': 'name and exam_id are required'}, 400)
|
||||||
|
|
||||||
|
start_date = _normalize_dt(body.get('start_date'))
|
||||||
|
end_date = _normalize_dt(body.get('end_date'))
|
||||||
|
if not start_date or not end_date:
|
||||||
|
return _json_response({'error': 'start_date and end_date are required'}, 400)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
try:
|
||||||
|
sd = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
sd = datetime.strptime(start_date[:19], '%Y-%m-%d %H:%M:%S')
|
||||||
|
try:
|
||||||
|
ed = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
ed = datetime.strptime(end_date[:19], '%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
if sd <= now and ed > now:
|
||||||
|
initial_state = 'active'
|
||||||
|
elif sd > now:
|
||||||
|
initial_state = 'planned'
|
||||||
|
else:
|
||||||
|
initial_state = 'past'
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'name': name,
|
||||||
|
'exam_id': int(exam_id),
|
||||||
|
'entity_id': body.get('entity_id') and int(body['entity_id']) or False,
|
||||||
|
'start_date': start_date,
|
||||||
|
'end_date': end_date,
|
||||||
|
'state': initial_state,
|
||||||
|
'assign_mode': body.get('assign_mode', 'batch'),
|
||||||
|
'full_length': body.get('full_length', True),
|
||||||
|
'generate_different': body.get('generate_different', False),
|
||||||
|
'auto_release_results': body.get('auto_release_results', False),
|
||||||
|
'auto_start': body.get('auto_start', False),
|
||||||
|
'official_exam': body.get('official_exam', False),
|
||||||
|
'hide_assignee_details': body.get('hide_assignee_details', False),
|
||||||
|
}
|
||||||
|
|
||||||
|
batch_ids = body.get('batch_ids', [])
|
||||||
|
if batch_ids:
|
||||||
|
vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])]
|
||||||
|
|
||||||
|
student_ids = body.get('student_ids', [])
|
||||||
|
if student_ids:
|
||||||
|
vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])]
|
||||||
|
|
||||||
|
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
|
||||||
|
|
||||||
|
self._create_individual_assignments(rec)
|
||||||
|
|
||||||
|
return _json_response(_schedule_to_dict(rec), 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('exam-schedule create failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def _create_individual_assignments(self, schedule):
|
||||||
|
"""Create individual assignment records for each targeted student."""
|
||||||
|
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||||
|
created_user_ids = set()
|
||||||
|
|
||||||
|
if schedule.student_ids:
|
||||||
|
for user in schedule.student_ids:
|
||||||
|
if user.id not in created_user_ids:
|
||||||
|
Assignment.create({
|
||||||
|
'exam_id': schedule.exam_id.id,
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'student_id': user.id,
|
||||||
|
'access_start': schedule.start_date,
|
||||||
|
'access_end': schedule.end_date,
|
||||||
|
'status': 'assigned',
|
||||||
|
})
|
||||||
|
created_user_ids.add(user.id)
|
||||||
|
|
||||||
|
for batch in schedule.batch_ids:
|
||||||
|
try:
|
||||||
|
students = request.env['op.student'].sudo().search([('batch_id', '=', batch.id)])
|
||||||
|
for student in students:
|
||||||
|
user = student.user_id if hasattr(student, 'user_id') else None
|
||||||
|
if user and user.id not in created_user_ids:
|
||||||
|
Assignment.create({
|
||||||
|
'exam_id': schedule.exam_id.id,
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'student_id': user.id,
|
||||||
|
'batch_id': batch.id,
|
||||||
|
'access_start': schedule.start_date,
|
||||||
|
'access_end': schedule.end_date,
|
||||||
|
'status': 'assigned',
|
||||||
|
})
|
||||||
|
created_user_ids.add(user.id)
|
||||||
|
except Exception:
|
||||||
|
_logger.warning('Batch student lookup failed for batch %s', batch.id)
|
||||||
|
|
||||||
|
if schedule.assign_mode == 'entity' and schedule.entity_id:
|
||||||
|
entity_users = schedule.entity_id.user_ids
|
||||||
|
for user in entity_users:
|
||||||
|
if user.id not in created_user_ids:
|
||||||
|
Assignment.create({
|
||||||
|
'exam_id': schedule.exam_id.id,
|
||||||
|
'schedule_id': schedule.id,
|
||||||
|
'student_id': user.id,
|
||||||
|
'access_start': schedule.start_date,
|
||||||
|
'access_end': schedule.end_date,
|
||||||
|
'status': 'assigned',
|
||||||
|
})
|
||||||
|
created_user_ids.add(user.id)
|
||||||
|
|
||||||
|
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
|
||||||
|
methods=['PUT'], csrf=False)
|
||||||
|
def update_schedule(self, schedule_id, **kw):
|
||||||
|
try:
|
||||||
|
user, err = _require_jwt()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _json_response({'error': 'Not found'}, 404)
|
||||||
|
|
||||||
|
body = json.loads(request.httprequest.data or '{}')
|
||||||
|
vals = {}
|
||||||
|
for f in ('name', 'assign_mode',
|
||||||
|
'full_length', 'generate_different', 'auto_release_results',
|
||||||
|
'auto_start', 'official_exam', 'hide_assignee_details'):
|
||||||
|
if f in body:
|
||||||
|
vals[f] = body[f]
|
||||||
|
if 'start_date' in body:
|
||||||
|
vals['start_date'] = _normalize_dt(body['start_date'])
|
||||||
|
if 'end_date' in body:
|
||||||
|
vals['end_date'] = _normalize_dt(body['end_date'])
|
||||||
|
if 'entity_id' in body:
|
||||||
|
vals['entity_id'] = body['entity_id'] and int(body['entity_id']) or False
|
||||||
|
if 'batch_ids' in body:
|
||||||
|
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
|
||||||
|
if 'student_ids' in body:
|
||||||
|
vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])]
|
||||||
|
if 'state' in body:
|
||||||
|
vals['state'] = body['state']
|
||||||
|
if vals:
|
||||||
|
rec.write(vals)
|
||||||
|
return _json_response(_schedule_to_dict(rec))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('exam-schedule update failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
def delete_schedule(self, schedule_id, **kw):
|
||||||
|
try:
|
||||||
|
user, err = _require_jwt()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _json_response({'error': 'Not found'}, 404)
|
||||||
|
rec.assignment_ids.unlink()
|
||||||
|
rec.unlink()
|
||||||
|
return _json_response({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('exam-schedule delete failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam-schedules/<int:schedule_id>/archive', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
def archive_schedule(self, schedule_id, **kw):
|
||||||
|
try:
|
||||||
|
user, err = _require_jwt()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _json_response({'error': 'Not found'}, 404)
|
||||||
|
rec.write({'state': 'archived'})
|
||||||
|
return _json_response(_schedule_to_dict(rec))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('exam-schedule archive failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/student/my-exams', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
def student_my_exams(self, **kw):
|
||||||
|
"""Return exam assignments for the current student user."""
|
||||||
|
try:
|
||||||
|
user, err = _require_jwt()
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||||
|
assignments = Assignment.search([
|
||||||
|
('student_id', '=', user.id),
|
||||||
|
('status', 'in', ['assigned', 'started']),
|
||||||
|
], order='access_start asc')
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for a in assignments:
|
||||||
|
schedule = a.schedule_id
|
||||||
|
state = schedule.state if schedule else 'active'
|
||||||
|
result.append({
|
||||||
|
'id': a.id,
|
||||||
|
'exam_id': a.exam_id.id,
|
||||||
|
'exam_title': a.exam_id.title if a.exam_id else '',
|
||||||
|
'schedule_name': schedule.name if schedule else '',
|
||||||
|
'start_date': a.access_start.isoformat() if a.access_start else None,
|
||||||
|
'end_date': a.access_end.isoformat() if a.access_end else None,
|
||||||
|
'status': a.status,
|
||||||
|
'schedule_state': state,
|
||||||
|
'auto_start': schedule.auto_start if schedule else False,
|
||||||
|
'can_start': state == 'active' and a.status == 'assigned',
|
||||||
|
})
|
||||||
|
return _json_response({'items': result})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('student my-exams failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
|
jwt_required, _json_response, _error_response, _get_json_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _question_to_student_dict(q):
|
||||||
|
return {
|
||||||
|
'id': q.id,
|
||||||
|
'skill': q.skill or '',
|
||||||
|
'question_type': q.question_type or '',
|
||||||
|
'stem': q.stem or '',
|
||||||
|
'options': json.loads(q.options) if q.options else [],
|
||||||
|
'marks': q.marks,
|
||||||
|
'difficulty': q.difficulty or '',
|
||||||
|
'source_type': q.source_type or '',
|
||||||
|
'source_id': q.source_id or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ExamSessionController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_session(self, exam_id, **kw):
|
||||||
|
try:
|
||||||
|
Exam = request.env['encoach.exam.custom'].sudo()
|
||||||
|
exam = Exam.browse(exam_id)
|
||||||
|
if not exam.exists():
|
||||||
|
return _error_response('Exam not found', 404)
|
||||||
|
|
||||||
|
uid = request.env.user.id
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
attempt = Attempt.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('exam_id', '=', exam.id),
|
||||||
|
('status', 'in', ['in_progress', 'scoring']),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
attempt = Attempt.create({
|
||||||
|
'student_id': uid,
|
||||||
|
'exam_id': exam.id,
|
||||||
|
'status': 'in_progress',
|
||||||
|
'entity_id': exam.entity_id.id if exam.entity_id else False,
|
||||||
|
})
|
||||||
|
|
||||||
|
Answer = request.env['encoach.student.answer'].sudo()
|
||||||
|
saved_answers = {}
|
||||||
|
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||||
|
saved_answers[ans.question_id.id] = ans.answer or ''
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for sec in exam.section_ids.sorted('sequence'):
|
||||||
|
questions = []
|
||||||
|
for q in sec.question_ids:
|
||||||
|
q_dict = _question_to_student_dict(q)
|
||||||
|
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
||||||
|
questions.append(q_dict)
|
||||||
|
|
||||||
|
sec_dict = {
|
||||||
|
'id': sec.id,
|
||||||
|
'title': sec.title,
|
||||||
|
'skill': sec.skill or '',
|
||||||
|
'difficulty': sec.difficulty or '',
|
||||||
|
'time_limit_min': sec.time_limit_min or 0,
|
||||||
|
'total_marks': sec.total_marks or 0,
|
||||||
|
'scoring_method': sec.scoring_method or 'auto',
|
||||||
|
'sequence': sec.sequence,
|
||||||
|
'questions': questions,
|
||||||
|
}
|
||||||
|
if sec.passage_text:
|
||||||
|
sec_dict['passage_text'] = sec.passage_text
|
||||||
|
if sec.instructions_text:
|
||||||
|
sec_dict['instructions_text'] = sec.instructions_text
|
||||||
|
if sec.content_json:
|
||||||
|
try:
|
||||||
|
sec_dict['content'] = json.loads(sec.content_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
sections.append(sec_dict)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'exam_id': exam.id,
|
||||||
|
'exam_title': exam.title,
|
||||||
|
'exam_mode': exam.exam_mode or 'official',
|
||||||
|
'total_time_min': exam.total_time_min or 0,
|
||||||
|
'total_marks': exam.total_marks or 0,
|
||||||
|
'grading_system': exam.grading_system or 'ielts',
|
||||||
|
'access_type': exam.access_type or 'private',
|
||||||
|
'randomize_questions': exam.randomize_questions or False,
|
||||||
|
'status': attempt.status,
|
||||||
|
'started_at': str(attempt.started_at) if attempt.started_at else None,
|
||||||
|
'sections': sections,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_session failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def autosave(self, exam_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
attempt_id = body.get('attempt_id')
|
||||||
|
raw_answers = body.get('answers', [])
|
||||||
|
|
||||||
|
if not attempt_id:
|
||||||
|
return _error_response('attempt_id is required', 400)
|
||||||
|
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
attempt = Attempt.browse(int(attempt_id))
|
||||||
|
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||||
|
return _error_response('Invalid attempt', 403)
|
||||||
|
|
||||||
|
answer_pairs = self._normalize_answers(raw_answers)
|
||||||
|
|
||||||
|
Answer = request.env['encoach.student.answer'].sudo()
|
||||||
|
saved = 0
|
||||||
|
for q_id, answer_val in answer_pairs:
|
||||||
|
existing = Answer.search([
|
||||||
|
('attempt_id', '=', attempt.id),
|
||||||
|
('question_id', '=', q_id),
|
||||||
|
], limit=1)
|
||||||
|
if existing:
|
||||||
|
existing.write({'answer': str(answer_val)})
|
||||||
|
else:
|
||||||
|
Answer.create({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': q_id,
|
||||||
|
'answer': str(answer_val),
|
||||||
|
})
|
||||||
|
saved += 1
|
||||||
|
|
||||||
|
return _json_response({'saved': saved})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('autosave failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def submit_exam(self, exam_id, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
attempt_id = body.get('attempt_id')
|
||||||
|
raw_answers = body.get('answers', [])
|
||||||
|
|
||||||
|
if not attempt_id:
|
||||||
|
return _error_response('attempt_id is required', 400)
|
||||||
|
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
attempt = Attempt.browse(int(attempt_id))
|
||||||
|
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||||
|
return _error_response('Invalid attempt', 403)
|
||||||
|
|
||||||
|
answer_pairs = self._normalize_answers(raw_answers)
|
||||||
|
|
||||||
|
Answer = request.env['encoach.student.answer'].sudo()
|
||||||
|
Question = request.env['encoach.question'].sudo()
|
||||||
|
|
||||||
|
total_score = 0.0
|
||||||
|
max_score = 0.0
|
||||||
|
|
||||||
|
for q_id, answer_val in answer_pairs:
|
||||||
|
q = Question.browse(q_id)
|
||||||
|
if not q.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_correct = False
|
||||||
|
score = 0.0
|
||||||
|
correct = (q.correct_answer or '').strip().lower()
|
||||||
|
given = str(answer_val).strip().lower()
|
||||||
|
|
||||||
|
if correct and given:
|
||||||
|
is_correct = correct == given
|
||||||
|
score = q.marks if is_correct else 0.0
|
||||||
|
|
||||||
|
existing = Answer.search([
|
||||||
|
('attempt_id', '=', attempt.id),
|
||||||
|
('question_id', '=', q_id),
|
||||||
|
], limit=1)
|
||||||
|
vals = {
|
||||||
|
'answer': str(answer_val),
|
||||||
|
'score': score,
|
||||||
|
'is_correct': is_correct,
|
||||||
|
}
|
||||||
|
if existing:
|
||||||
|
existing.write(vals)
|
||||||
|
else:
|
||||||
|
vals.update({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'question_id': q_id,
|
||||||
|
})
|
||||||
|
Answer.create(vals)
|
||||||
|
|
||||||
|
total_score += score
|
||||||
|
max_score += q.marks
|
||||||
|
|
||||||
|
from odoo.fields import Datetime
|
||||||
|
attempt.write({
|
||||||
|
'status': 'completed',
|
||||||
|
'finished_at': Datetime.now(),
|
||||||
|
'total_score': total_score,
|
||||||
|
'max_score': max_score,
|
||||||
|
})
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'status': 'completed',
|
||||||
|
'total_score': total_score,
|
||||||
|
'max_score': max_score,
|
||||||
|
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
|
||||||
|
'results_available': True,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('submit_exam failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_status(self, exam_id, **kw):
|
||||||
|
try:
|
||||||
|
uid = request.env.user.id
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
attempt = Attempt.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('exam_id', '=', exam_id),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
return _error_response('No attempt found', 404)
|
||||||
|
|
||||||
|
scores_available = attempt.status == 'completed' and attempt.max_score > 0
|
||||||
|
return _json_response({
|
||||||
|
'status': attempt.status,
|
||||||
|
'scores_available': scores_available,
|
||||||
|
'total_score': attempt.total_score,
|
||||||
|
'max_score': attempt.max_score,
|
||||||
|
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_status failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def get_results(self, exam_id, **kw):
|
||||||
|
try:
|
||||||
|
uid = request.env.user.id
|
||||||
|
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||||
|
attempt = Attempt.search([
|
||||||
|
('student_id', '=', uid),
|
||||||
|
('exam_id', '=', exam_id),
|
||||||
|
('status', '=', 'completed'),
|
||||||
|
], limit=1, order='id desc')
|
||||||
|
|
||||||
|
if not attempt:
|
||||||
|
return _error_response('No completed attempt found', 404)
|
||||||
|
|
||||||
|
Answer = request.env['encoach.student.answer'].sudo()
|
||||||
|
answers = []
|
||||||
|
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
|
||||||
|
answers.append({
|
||||||
|
'question_id': ans.question_id.id,
|
||||||
|
'answer': ans.answer or '',
|
||||||
|
'score': ans.score,
|
||||||
|
'is_correct': ans.is_correct,
|
||||||
|
'feedback': ans.feedback or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
Exam = request.env['encoach.exam.custom'].sudo()
|
||||||
|
exam = Exam.browse(exam_id)
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'attempt_id': attempt.id,
|
||||||
|
'exam_id': exam_id,
|
||||||
|
'exam_title': exam.title if exam.exists() else '',
|
||||||
|
'status': attempt.status,
|
||||||
|
'total_score': attempt.total_score,
|
||||||
|
'max_score': attempt.max_score,
|
||||||
|
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
|
||||||
|
'started_at': str(attempt.started_at) if attempt.started_at else None,
|
||||||
|
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
|
||||||
|
'answers': answers,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('get_results failed')
|
||||||
|
return _error_response(str(e), 500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_answers(raw):
|
||||||
|
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
|
||||||
|
if isinstance(raw, list):
|
||||||
|
pairs = []
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
qid = item.get('question_id') or item.get('qid')
|
||||||
|
ans = item.get('answer', '')
|
||||||
|
if qid:
|
||||||
|
pairs.append((int(qid), ans))
|
||||||
|
return pairs
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return [(int(k), v) for k, v in raw.items()]
|
||||||
|
return []
|
||||||
@@ -3,24 +3,17 @@ import logging
|
|||||||
|
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request
|
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__)
|
_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):
|
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):
|
def list_structures(self, **kw):
|
||||||
domain = [('active', '=', True)]
|
domain = [('active', '=', True)]
|
||||||
entity_id = kw.get('entity_id')
|
entity_id = kw.get('entity_id')
|
||||||
@@ -29,8 +22,9 @@ class ExamStructureController(http.Controller):
|
|||||||
|
|
||||||
limit = int(kw.get('limit', 50))
|
limit = int(kw.get('limit', 50))
|
||||||
offset = int(kw.get('offset', 0))
|
offset = int(kw.get('offset', 0))
|
||||||
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
|
records = request.env['encoach.exam.structure'].sudo().search(
|
||||||
total = request.env['encoach.exam.structure'].search_count(domain)
|
domain, limit=limit, offset=offset, order='create_date desc')
|
||||||
|
total = request.env['encoach.exam.structure'].sudo().search_count(domain)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for r in records:
|
for r in records:
|
||||||
@@ -52,12 +46,13 @@ class ExamStructureController(http.Controller):
|
|||||||
|
|
||||||
return _json_response({'items': items, 'total': total})
|
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):
|
def create_structure(self, **kw):
|
||||||
body = _json_body()
|
body = _get_json_body()
|
||||||
name = body.get('name')
|
name = body.get('name')
|
||||||
if not name:
|
if not name:
|
||||||
return _json_response({'error': 'name is required'}, status=400)
|
return _error_response('name is required', 400)
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -69,19 +64,61 @@ class ExamStructureController(http.Controller):
|
|||||||
if entity_id:
|
if entity_id:
|
||||||
vals['entity_id'] = int(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({
|
return _json_response({
|
||||||
'id': record.id,
|
'id': record.id,
|
||||||
'name': record.name,
|
'name': record.name,
|
||||||
'entity_id': record.entity_id.id if record.entity_id else None,
|
'entity_id': record.entity_id.id if record.entity_id else None,
|
||||||
'industry': record.industry or '',
|
'industry': record.industry or '',
|
||||||
'modules': json.loads(record.modules) if record.modules else [],
|
'modules': json.loads(record.modules) if record.modules else [],
|
||||||
|
'config': json.loads(record.config) if record.config else {},
|
||||||
})
|
})
|
||||||
|
|
||||||
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
|
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||||
def delete_structure(self, structure_id, **kw):
|
@jwt_required
|
||||||
record = request.env['encoach.exam.structure'].browse(structure_id)
|
def update_structure(self, structure_id, **kw):
|
||||||
|
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
|
||||||
if not record.exists():
|
if not record.exists():
|
||||||
return _json_response({'error': 'Structure not found'}, status=404)
|
return _error_response('Structure not found', 404)
|
||||||
|
|
||||||
|
body = _get_json_body()
|
||||||
|
vals = {}
|
||||||
|
if 'name' in body:
|
||||||
|
vals['name'] = body['name']
|
||||||
|
if 'industry' in body:
|
||||||
|
vals['industry'] = body['industry']
|
||||||
|
if 'modules' in body:
|
||||||
|
vals['modules'] = json.dumps(body['modules'])
|
||||||
|
if 'config' in body:
|
||||||
|
vals['config'] = json.dumps(body['config'])
|
||||||
|
if 'entity_id' in body:
|
||||||
|
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
|
||||||
|
|
||||||
|
if vals:
|
||||||
|
record.write(vals)
|
||||||
|
|
||||||
|
modules = []
|
||||||
|
if record.modules:
|
||||||
|
try:
|
||||||
|
modules = json.loads(record.modules)
|
||||||
|
except Exception:
|
||||||
|
modules = []
|
||||||
|
|
||||||
|
return _json_response({
|
||||||
|
'id': record.id,
|
||||||
|
'name': record.name,
|
||||||
|
'entity_id': record.entity_id.id if record.entity_id else None,
|
||||||
|
'entity_name': record.entity_id.name if record.entity_id else None,
|
||||||
|
'industry': record.industry or '',
|
||||||
|
'modules': modules,
|
||||||
|
'config': json.loads(record.config) if record.config else {},
|
||||||
|
})
|
||||||
|
|
||||||
|
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def delete_structure(self, structure_id, **kw):
|
||||||
|
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
|
||||||
|
if not record.exists():
|
||||||
|
return _error_response('Structure not found', 404)
|
||||||
record.unlink()
|
record.unlink()
|
||||||
return _json_response({'success': True})
|
return _json_response({'success': True})
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import logging
|
|||||||
|
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _json_response(data, status=200):
|
|
||||||
return request.make_json_response(data, status=status)
|
|
||||||
|
|
||||||
|
|
||||||
def _rubric_to_dict(rec):
|
def _rubric_to_dict(rec):
|
||||||
criteria_text = rec.criteria or ''
|
criteria_text = rec.criteria or ''
|
||||||
criteria_count = 0
|
criteria_count = 0
|
||||||
@@ -26,6 +25,15 @@ def _rubric_to_dict(rec):
|
|||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
criteria_count = len([l for l in criteria_text.split('\n') if l.strip()])
|
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 {
|
return {
|
||||||
'id': rec.id,
|
'id': rec.id,
|
||||||
'name': rec.name,
|
'name': rec.name,
|
||||||
@@ -33,15 +41,16 @@ def _rubric_to_dict(rec):
|
|||||||
'exam_type': rec.exam_type or '',
|
'exam_type': rec.exam_type or '',
|
||||||
'criteria': criteria_count or 1,
|
'criteria': criteria_count or 1,
|
||||||
'criteria_text': criteria_text,
|
'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 '',
|
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class EncoachRubricController(http.Controller):
|
class EncoachRubricController(http.Controller):
|
||||||
|
|
||||||
@http.route('/api/rubrics', type='http', auth='user',
|
@http.route('/api/rubrics', type='http', auth='none',
|
||||||
methods=['GET'], csrf=False)
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
def list_rubrics(self, **kw):
|
def list_rubrics(self, **kw):
|
||||||
try:
|
try:
|
||||||
Rubric = request.env['encoach.rubric'].sudo()
|
Rubric = request.env['encoach.rubric'].sudo()
|
||||||
@@ -58,14 +67,15 @@ class EncoachRubricController(http.Controller):
|
|||||||
_logger.exception('rubrics list failed')
|
_logger.exception('rubrics list failed')
|
||||||
return _json_response({'error': str(e)}, 500)
|
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)
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
def create_rubric(self, **kw):
|
def create_rubric(self, **kw):
|
||||||
try:
|
try:
|
||||||
body = json.loads(request.httprequest.data or '{}')
|
body = _get_json_body()
|
||||||
name = body.get('name', '').strip()
|
name = body.get('name', '').strip()
|
||||||
if not name:
|
if not name:
|
||||||
return _json_response({'error': 'name is required'}, 400)
|
return _error_response('name is required', 400)
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -73,8 +83,140 @@ class EncoachRubricController(http.Controller):
|
|||||||
'criteria': body.get('criteria', ''),
|
'criteria': body.get('criteria', ''),
|
||||||
'exam_type': body.get('exam_type', 'academic'),
|
'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)
|
return _json_response(_rubric_to_dict(rec), 201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('rubric create failed')
|
_logger.exception('rubric create failed')
|
||||||
return _json_response({'error': str(e)}, 500)
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
|
||||||
|
methods=['PUT'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def update_rubric(self, rubric_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _error_response('Rubric not found', 404)
|
||||||
|
|
||||||
|
body = _get_json_body()
|
||||||
|
vals = {}
|
||||||
|
if 'name' in body:
|
||||||
|
vals['name'] = body['name']
|
||||||
|
if 'skill' in body:
|
||||||
|
vals['skill'] = body['skill']
|
||||||
|
if 'criteria' in body:
|
||||||
|
vals['criteria'] = body['criteria']
|
||||||
|
if 'exam_type' in body:
|
||||||
|
vals['exam_type'] = body['exam_type']
|
||||||
|
if 'levels' in body:
|
||||||
|
vals['levels'] = json.dumps(body['levels'])
|
||||||
|
|
||||||
|
if vals:
|
||||||
|
rec.write(vals)
|
||||||
|
|
||||||
|
return _json_response(_rubric_to_dict(rec))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric update failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def delete_rubric(self, rubric_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _error_response('Rubric not found', 404)
|
||||||
|
rec.unlink()
|
||||||
|
return _json_response({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric delete failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_to_dict(rec):
|
||||||
|
return {
|
||||||
|
'id': rec.id,
|
||||||
|
'name': rec.name,
|
||||||
|
'rubric_ids': rec.rubric_ids.ids,
|
||||||
|
'rubric_names': [r.name for r in rec.rubric_ids],
|
||||||
|
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachRubricGroupController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/api/rubric-groups', type='http', auth='none',
|
||||||
|
methods=['GET'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def list_rubric_groups(self, **kw):
|
||||||
|
try:
|
||||||
|
Group = request.env['encoach.rubric.group'].sudo()
|
||||||
|
limit = int(kw.get('limit', 50))
|
||||||
|
offset = int(kw.get('offset', 0))
|
||||||
|
records = Group.search([], limit=limit, offset=offset,
|
||||||
|
order='create_date desc')
|
||||||
|
total = Group.search_count([])
|
||||||
|
return _json_response({
|
||||||
|
'items': [_group_to_dict(r) for r in records],
|
||||||
|
'total': total,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric-groups list failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/rubric-groups', type='http', auth='none',
|
||||||
|
methods=['POST'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def create_rubric_group(self, **kw):
|
||||||
|
try:
|
||||||
|
body = _get_json_body()
|
||||||
|
name = body.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return _error_response('name is required', 400)
|
||||||
|
rubric_ids = body.get('rubric_ids', [])
|
||||||
|
rec = request.env['encoach.rubric.group'].sudo().create({
|
||||||
|
'name': name,
|
||||||
|
'rubric_ids': [(6, 0, rubric_ids)],
|
||||||
|
})
|
||||||
|
return _json_response(_group_to_dict(rec), 201)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric-group create failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
|
||||||
|
methods=['PUT'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def update_rubric_group(self, group_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _error_response('Group not found', 404)
|
||||||
|
body = _get_json_body()
|
||||||
|
vals = {}
|
||||||
|
if 'name' in body:
|
||||||
|
vals['name'] = body['name']
|
||||||
|
if 'rubric_ids' in body:
|
||||||
|
vals['rubric_ids'] = [(6, 0, body['rubric_ids'])]
|
||||||
|
if vals:
|
||||||
|
rec.write(vals)
|
||||||
|
return _json_response(_group_to_dict(rec))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric-group update failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
|
||||||
|
methods=['DELETE'], csrf=False)
|
||||||
|
@jwt_required
|
||||||
|
def delete_rubric_group(self, group_id, **kw):
|
||||||
|
try:
|
||||||
|
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
|
||||||
|
if not rec.exists():
|
||||||
|
return _error_response('Group not found', 404)
|
||||||
|
rec.unlink()
|
||||||
|
return _json_response({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.exception('rubric-group delete failed')
|
||||||
|
return _json_response({'error': str(e)}, 500)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="ir_cron_exam_schedule_lifecycle" model="ir.cron">
|
||||||
|
<field name="name">Exam Schedule Lifecycle</field>
|
||||||
|
<field name="model_id" ref="model_encoach_exam_schedule"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model.update_lifecycle_states()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from . import rubric
|
from . import rubric
|
||||||
|
from . import rubric_group
|
||||||
from . import exam_template
|
from . import exam_template
|
||||||
from . import passage
|
from . import passage
|
||||||
from . import audio_file
|
from . import audio_file
|
||||||
@@ -8,4 +9,6 @@ from . import speaking_card
|
|||||||
from . import exam_custom
|
from . import exam_custom
|
||||||
from . import exam_custom_section
|
from . import exam_custom_section
|
||||||
from . import exam_assignment
|
from . import exam_assignment
|
||||||
|
from . import exam_schedule
|
||||||
from . import exam_structure
|
from . import exam_structure
|
||||||
|
from . import student_attempt
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class EncoachExamAssignment(models.Model):
|
|||||||
_description = 'Exam Assignment'
|
_description = 'Exam Assignment'
|
||||||
|
|
||||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
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')
|
student_id = fields.Many2one('res.users', ondelete='cascade')
|
||||||
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
||||||
access_start = fields.Datetime()
|
access_start = fields.Datetime()
|
||||||
|
|||||||
@@ -6,13 +6,32 @@ class EncoachExamCustom(models.Model):
|
|||||||
_description = 'Custom Exam'
|
_description = 'Custom Exam'
|
||||||
|
|
||||||
title = fields.Char(size=200, required=True)
|
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')
|
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')
|
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
teacher_id = fields.Many2one('res.users', 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()
|
description = fields.Text()
|
||||||
total_time_min = fields.Integer()
|
total_time_min = fields.Integer()
|
||||||
|
total_marks = fields.Float()
|
||||||
pass_threshold = 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([
|
results_release_mode = fields.Selection([
|
||||||
('auto', 'Auto'),
|
('auto', 'Auto'),
|
||||||
('manual_approval', 'Manual Approval'),
|
('manual_approval', 'Manual Approval'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from odoo import models, fields
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
@@ -8,14 +9,19 @@ class EncoachExamCustomSection(models.Model):
|
|||||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||||
title = fields.Char(size=200, required=True)
|
title = fields.Char(size=200, required=True)
|
||||||
skill = fields.Char(size=100)
|
skill = fields.Char(size=100)
|
||||||
|
difficulty = fields.Char(size=50)
|
||||||
question_count = fields.Integer()
|
question_count = fields.Integer()
|
||||||
time_limit_min = fields.Integer()
|
time_limit_min = fields.Integer()
|
||||||
|
total_marks = fields.Float()
|
||||||
scoring_method = fields.Selection([
|
scoring_method = fields.Selection([
|
||||||
('auto', 'Auto'),
|
('auto', 'Auto'),
|
||||||
('rubric', 'Rubric'),
|
('rubric', 'Rubric'),
|
||||||
('mixed', 'Mixed'),
|
('mixed', 'Mixed'),
|
||||||
], default='auto')
|
], default='auto')
|
||||||
sequence = fields.Integer(default=10)
|
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(
|
question_ids = fields.Many2many(
|
||||||
'encoach.question',
|
'encoach.question',
|
||||||
'exam_custom_section_question_rel',
|
'exam_custom_section_question_rel',
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from odoo import models, fields, api
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachExamSchedule(models.Model):
|
||||||
|
_name = 'encoach.exam.schedule'
|
||||||
|
_description = 'Exam Schedule / Assignment Group'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(required=True, size=200)
|
||||||
|
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
|
|
||||||
|
start_date = fields.Datetime(required=True)
|
||||||
|
end_date = fields.Datetime(required=True)
|
||||||
|
|
||||||
|
full_length = fields.Boolean(default=True)
|
||||||
|
generate_different = fields.Boolean(default=False)
|
||||||
|
auto_release_results = fields.Boolean(default=False)
|
||||||
|
auto_start = fields.Boolean(default=False)
|
||||||
|
official_exam = fields.Boolean(default=False)
|
||||||
|
hide_assignee_details = fields.Boolean(default=False)
|
||||||
|
|
||||||
|
assign_mode = fields.Selection([
|
||||||
|
('entity', 'Entire Entity'),
|
||||||
|
('batch', 'Class / Batch'),
|
||||||
|
('individual', 'Individual Students'),
|
||||||
|
], default='batch', required=True)
|
||||||
|
|
||||||
|
batch_ids = fields.Many2many('op.batch', string='Classes')
|
||||||
|
student_ids = fields.Many2many('res.users', 'exam_schedule_student_rel',
|
||||||
|
'schedule_id', 'user_id', string='Students')
|
||||||
|
|
||||||
|
state = fields.Selection([
|
||||||
|
('planned', 'Planned'),
|
||||||
|
('active', 'Active'),
|
||||||
|
('past', 'Past'),
|
||||||
|
('start_expired', 'Start Expired'),
|
||||||
|
('archived', 'Archived'),
|
||||||
|
], default='planned', required=True, index=True)
|
||||||
|
|
||||||
|
assignment_ids = fields.One2many('encoach.exam.assignment', 'schedule_id')
|
||||||
|
assignee_count = fields.Integer(compute='_compute_counts', store=True)
|
||||||
|
completed_count = fields.Integer(compute='_compute_counts', store=True)
|
||||||
|
|
||||||
|
@api.depends('assignment_ids', 'assignment_ids.status')
|
||||||
|
def _compute_counts(self):
|
||||||
|
for rec in self:
|
||||||
|
assignments = rec.assignment_ids
|
||||||
|
rec.assignee_count = len(assignments)
|
||||||
|
rec.completed_count = len(assignments.filtered(lambda a: a.status == 'completed'))
|
||||||
|
|
||||||
|
def update_lifecycle_states(self):
|
||||||
|
"""Cron job: transition schedules based on current time."""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '>', now)])
|
||||||
|
planned.write({'state': 'active'})
|
||||||
|
|
||||||
|
expired_planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '<=', now)])
|
||||||
|
expired_planned.write({'state': 'start_expired'})
|
||||||
|
|
||||||
|
active_ended = self.search([('state', '=', 'active'), ('end_date', '<=', now)])
|
||||||
|
active_ended.write({'state': 'past'})
|
||||||
|
for rec in active_ended:
|
||||||
|
rec.assignment_ids.filtered(
|
||||||
|
lambda a: a.status in ('assigned', 'started')
|
||||||
|
).write({'status': 'expired'})
|
||||||
@@ -11,6 +11,7 @@ class EncoachRubric(models.Model):
|
|||||||
('speaking', 'Speaking'),
|
('speaking', 'Speaking'),
|
||||||
], required=True)
|
], required=True)
|
||||||
criteria = fields.Text(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([
|
exam_type = fields.Selection([
|
||||||
('academic', 'Academic'),
|
('academic', 'Academic'),
|
||||||
('general_training', 'General Training'),
|
('general_training', 'General Training'),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachRubricGroup(models.Model):
|
||||||
|
_name = 'encoach.rubric.group'
|
||||||
|
_description = 'Rubric Group'
|
||||||
|
|
||||||
|
name = fields.Char(size=200, required=True)
|
||||||
|
rubric_ids = fields.Many2many(
|
||||||
|
'encoach.rubric',
|
||||||
|
'encoach_rubric_group_rel',
|
||||||
|
'group_id',
|
||||||
|
'rubric_id',
|
||||||
|
string='Rubrics',
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from odoo import models, fields
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachStudentAttempt(models.Model):
|
||||||
|
_name = 'encoach.student.attempt'
|
||||||
|
_description = 'Student Exam Attempt'
|
||||||
|
_order = 'id desc'
|
||||||
|
|
||||||
|
student_id = fields.Many2one('res.users', required=True, ondelete='cascade')
|
||||||
|
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
|
status = fields.Selection([
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('scoring', 'Scoring'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('abandoned', 'Abandoned'),
|
||||||
|
], default='in_progress', required=True)
|
||||||
|
started_at = fields.Datetime(default=fields.Datetime.now)
|
||||||
|
finished_at = fields.Datetime()
|
||||||
|
overall_band = fields.Float()
|
||||||
|
cefr_level = fields.Char(size=10)
|
||||||
|
listening_band = fields.Float()
|
||||||
|
reading_band = fields.Float()
|
||||||
|
writing_band = fields.Float()
|
||||||
|
speaking_band = fields.Float()
|
||||||
|
total_score = fields.Float()
|
||||||
|
max_score = fields.Float()
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachStudentAnswer(models.Model):
|
||||||
|
_name = 'encoach.student.answer'
|
||||||
|
_description = 'Student Answer'
|
||||||
|
|
||||||
|
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
|
||||||
|
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
|
||||||
|
answer = fields.Text()
|
||||||
|
score = fields.Float()
|
||||||
|
is_correct = fields.Boolean()
|
||||||
|
feedback = fields.Text()
|
||||||
|
|
||||||
|
|
||||||
|
class EncoachStudentScore(models.Model):
|
||||||
|
_name = 'encoach.student.score'
|
||||||
|
_description = 'Student Skill Score'
|
||||||
|
|
||||||
|
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
|
||||||
|
skill = fields.Char(size=50, required=True)
|
||||||
|
band_score = fields.Float()
|
||||||
|
raw_score = fields.Float()
|
||||||
|
max_score = fields.Float()
|
||||||
|
cefr_level = fields.Char(size=10)
|
||||||
|
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||||
@@ -6,7 +6,12 @@ access_encoach_question_user,encoach.question.user,model_encoach_question,base.g
|
|||||||
access_encoach_writing_prompt_user,encoach.writing.prompt.user,model_encoach_writing_prompt,base.group_user,1,1,1,1
|
access_encoach_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_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_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_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_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_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_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
|
||||||
|
access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_student_attempt,base.group_user,1,1,1,1
|
||||||
|
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
|
||||||
|
access_encoach_student_score_user,encoach.student.score.user,model_encoach_student_score,base.group_user,1,1,1,1
|
||||||
|
|||||||
|
@@ -186,21 +186,38 @@ class EncoachExamSessionController(http.Controller):
|
|||||||
q_dict = _question_to_student_dict(q)
|
q_dict = _question_to_student_dict(q)
|
||||||
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
q_dict['saved_answer'] = saved_answers.get(q.id, '')
|
||||||
questions.append(q_dict)
|
questions.append(q_dict)
|
||||||
sections.append({
|
sec_dict = {
|
||||||
'id': sec.id,
|
'id': sec.id,
|
||||||
'title': sec.title,
|
'title': sec.title,
|
||||||
'skill': sec.skill or '',
|
'skill': sec.skill or '',
|
||||||
|
'difficulty': sec.difficulty or '',
|
||||||
'time_limit_min': sec.time_limit_min or 0,
|
'time_limit_min': sec.time_limit_min or 0,
|
||||||
|
'total_marks': sec.total_marks or 0,
|
||||||
'scoring_method': sec.scoring_method or 'auto',
|
'scoring_method': sec.scoring_method or 'auto',
|
||||||
'sequence': sec.sequence,
|
'sequence': sec.sequence,
|
||||||
'questions': questions,
|
'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({
|
return _json_response({
|
||||||
'attempt_id': attempt.id,
|
'attempt_id': attempt.id,
|
||||||
'exam_id': exam.id,
|
'exam_id': exam.id,
|
||||||
'exam_title': exam.title,
|
'exam_title': exam.title,
|
||||||
|
'exam_mode': exam.exam_mode or 'official',
|
||||||
'total_time_min': exam.total_time_min or 0,
|
'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,
|
'status': attempt.status,
|
||||||
'started_at': attempt.started_at,
|
'started_at': attempt.started_at,
|
||||||
'sections': sections,
|
'sections': sections,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
|
import ExamPopup from "./student/ExamPopup";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
||||||
@@ -47,5 +48,10 @@ const navGroups: NavGroup[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function StudentLayout() {
|
export default function StudentLayout() {
|
||||||
return <RoleLayout navGroups={navGroups} role="student" />;
|
return (
|
||||||
|
<>
|
||||||
|
<RoleLayout navGroups={navGroups} role="student" />
|
||||||
|
<ExamPopup />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
119
frontend/src/components/student/ExamPopup.tsx
Normal file
119
frontend/src/components/student/ExamPopup.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
|
||||||
|
import { assignmentsService } from "@/services/assignments.service";
|
||||||
|
import type { StudentExamAssignment } from "@/types";
|
||||||
|
|
||||||
|
export default function ExamPopup() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["student-my-exams"],
|
||||||
|
queryFn: () => assignmentsService.getStudentExams(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exams = (data?.items ?? []) as StudentExamAssignment[];
|
||||||
|
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingExams.length > 0 && !open) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [pendingExams.length]);
|
||||||
|
|
||||||
|
if (pendingExams.length === 0) return null;
|
||||||
|
|
||||||
|
const formatDate = (iso: string | null) => {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
|
||||||
|
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = (exam: StudentExamAssignment) => {
|
||||||
|
setOpen(false);
|
||||||
|
navigate(`/student/exam/${exam.exam_id}/session`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = (id: number) => {
|
||||||
|
setDismissed((prev) => new Set([...prev, id]));
|
||||||
|
const remaining = pendingExams.filter((e) => e.id !== id);
|
||||||
|
if (remaining.length === 0) setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-primary" />
|
||||||
|
Upcoming Exams ({pendingExams.length})
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||||
|
{pendingExams.map((exam) => (
|
||||||
|
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
||||||
|
className="capitalize text-xs"
|
||||||
|
>
|
||||||
|
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exam.schedule_name && exam.exam_title && (
|
||||||
|
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
From: {formatDate(exam.start_date)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
To: {formatDate(exam.end_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!exam.can_start}
|
||||||
|
onClick={() => handleStart(exam)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
|
{exam.can_start ? "Start Exam" : "Not Available Yet"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDismiss(exam.id)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
{!exam.can_start && (
|
||||||
|
<span className="text-[11px] text-muted-foreground italic ml-auto">
|
||||||
|
{exam.schedule_state === "planned"
|
||||||
|
? "Exam will be available once it becomes active"
|
||||||
|
: "Exam is not currently active"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
|
import {
|
||||||
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
const workflows = [
|
interface WorkflowStep {
|
||||||
{
|
id: number;
|
||||||
id: 1, name: "Exam Content Review", status: "In Progress",
|
sequence: number;
|
||||||
steps: [
|
approver_id: number | null;
|
||||||
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
|
approver_name: string;
|
||||||
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
|
status: string;
|
||||||
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
|
comment: string;
|
||||||
],
|
auto_escalate: boolean;
|
||||||
},
|
max_days: number;
|
||||||
{
|
acted_at: string | null;
|
||||||
id: 2, name: "Rubric Approval", status: "Completed",
|
}
|
||||||
steps: [
|
|
||||||
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
|
interface Workflow {
|
||||||
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
|
id: number;
|
||||||
],
|
name: string;
|
||||||
},
|
type: string;
|
||||||
{
|
entity_id: number | null;
|
||||||
id: 3, name: "New Exam Structure", status: "Rejected",
|
entity_name: string | null;
|
||||||
steps: [
|
allow_bypass: boolean;
|
||||||
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
|
active: boolean;
|
||||||
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
|
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) => {
|
const statusIcon = (s: string) => {
|
||||||
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
|
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||||
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
|
||||||
return <Clock className="h-4 w-4 text-warning" />;
|
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateBadge = (state: string) => {
|
||||||
|
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
|
approved: "default",
|
||||||
|
in_progress: "secondary",
|
||||||
|
rejected: "destructive",
|
||||||
|
draft: "outline",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge variant={map[state] || "outline"} className="capitalize">
|
||||||
|
{state.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApprovalWorkflowsPage() {
|
export default function ApprovalWorkflowsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formType, setFormType] = useState("custom");
|
||||||
|
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
|
||||||
|
{ approver_id: "" },
|
||||||
|
{ approver_id: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
|
||||||
|
const [rejectComment, setRejectComment] = useState("");
|
||||||
|
|
||||||
|
const workflowsQ = useQuery({
|
||||||
|
queryKey: ["approval-workflows"],
|
||||||
|
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestsQ = useQuery({
|
||||||
|
queryKey: ["approval-requests"],
|
||||||
|
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersQ = useQuery({
|
||||||
|
queryKey: ["approval-users"],
|
||||||
|
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflows = workflowsQ.data?.items ?? [];
|
||||||
|
const requests = requestsQ.data?.items ?? [];
|
||||||
|
const users = usersQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetForm();
|
||||||
|
toast({ title: "Workflow created" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
toast({ title: "Workflow deleted" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
toast({ title: "Request approved" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMut = useMutation({
|
||||||
|
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
|
||||||
|
api.post(`/approval-requests/${id}/reject`, { comment }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-requests"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectComment("");
|
||||||
|
toast({ title: "Request rejected" });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormName("");
|
||||||
|
setFormType("custom");
|
||||||
|
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
createMut.mutate({
|
||||||
|
name: formName.trim(),
|
||||||
|
type: formType,
|
||||||
|
steps: formSteps
|
||||||
|
.filter((s) => s.approver_id)
|
||||||
|
.map((s) => ({ approver_id: Number(s.approver_id) })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRequests = requests.filter((r) => r.state === "in_progress");
|
||||||
|
const completedRequests = requests.filter((r) => r.state !== "in_progress");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
|
||||||
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
|
<p className="text-muted-foreground">
|
||||||
|
Manage multi-step approval processes for exam content.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog>
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
|
<Button size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" /> Create Workflow
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Workflow</DialogTitle>
|
||||||
|
<DialogDescription>Define a multi-step approval process.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Step 1 — Assignee</Label>
|
<Label>
|
||||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
Workflow Name <span className="text-destructive">*</span>
|
||||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
|
</Label>
|
||||||
</Select>
|
<Input
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder="e.g. Exam Content Review"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Step 2 — Assignee</Label>
|
<Label>Type</Label>
|
||||||
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
|
<Select value={formType} onValueChange={setFormType}>
|
||||||
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
<SelectItem value="exam_publish">Exam Publication</SelectItem>
|
||||||
|
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
|
||||||
|
<SelectItem value="content_publish">Content Publication</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full">Create</Button>
|
|
||||||
|
{formSteps.map((step, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Step {i + 1} — Approver</Label>
|
||||||
|
{formSteps.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setFormSteps((s) => s.filter((_, idx) => idx !== i))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={step.approver_id}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFormSteps((s) =>
|
||||||
|
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select approver" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={String(u.id)}>
|
||||||
|
{u.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Step
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!formName.trim() || createMut.isPending}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
{createMut.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Loading */}
|
||||||
|
{(workflowsQ.isLoading || requestsQ.isLoading) && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Requests */}
|
||||||
|
{pendingRequests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Clock className="h-5 w-5 text-amber-500" />
|
||||||
|
Pending Approval ({pendingRequests.length})
|
||||||
|
</h2>
|
||||||
|
{pendingRequests.map((req) => {
|
||||||
|
const wf = workflows.find((w) => w.id === req.workflow_id);
|
||||||
|
return (
|
||||||
|
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{req.workflow_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Requested by {req.requester_name} ·{" "}
|
||||||
|
{req.res_model.replace("encoach.", "")} #{req.res_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stateBadge(req.state)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wf && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
{wf.steps.map((step, i) => {
|
||||||
|
const isActive = step.id === req.current_stage_id;
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
|
||||||
|
isActive
|
||||||
|
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
|
||||||
|
: step.status === "approved"
|
||||||
|
? "border-green-200 bg-green-50/50"
|
||||||
|
: step.status === "rejected"
|
||||||
|
? "border-red-200 bg-red-50/50"
|
||||||
|
: "bg-muted/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusIcon(step.status)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Step {i + 1}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{step.approver_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{i < wf.steps.length - 1 && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={approveMut.isPending}
|
||||||
|
onClick={() => approveMut.mutate(req.id)}
|
||||||
|
>
|
||||||
|
{approveMut.isPending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
)}
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialog(req.id)}
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Templates */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
Workflow Templates ({workflows.length})
|
||||||
|
</h2>
|
||||||
|
{workflows.length === 0 && !workflowsQ.isLoading && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No workflows defined yet. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{workflows.map((wf) => (
|
{workflows.map((wf) => (
|
||||||
<Card key={wf.id} className="border-0 shadow-sm">
|
<Card key={wf.id} className="border-0 shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
|
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">
|
||||||
|
{wf.type.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{wf.steps.map((step, i) => (
|
{wf.steps.map((step, i) => (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={step.id} className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
|
||||||
{statusIcon(step.status)}
|
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{step.name}</p>
|
<p className="text-sm font-medium">{step.approver_name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{step.assignee}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{step.max_days}d limit
|
||||||
|
{step.auto_escalate ? " · auto-escalate" : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
|
{i < wf.steps.length - 1 && (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{wf.status === "In Progress" && (
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<AiGradingAssistant onAccept={(marks, feedback) => {
|
|
||||||
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
|
|
||||||
}} />
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" variant="default">Approve</Button>
|
|
||||||
<Button size="sm" variant="outline">Reject</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Requests */}
|
||||||
|
{completedRequests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
Recent Decisions ({completedRequests.length})
|
||||||
|
</h2>
|
||||||
|
{completedRequests.map((req) => (
|
||||||
|
<Card key={req.id} className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{req.workflow_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{req.requester_name} · {req.res_model.replace("encoach.", "")} #
|
||||||
|
{req.res_id}
|
||||||
|
{req.created_at && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· {new Date(req.created_at).toLocaleDateString()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stateBadge(req.state)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reject Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={rejectDialog !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectComment("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reject Request</DialogTitle>
|
||||||
|
<DialogDescription>Provide a reason for rejection.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Comment</Label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectComment}
|
||||||
|
onChange={(e) => setRejectComment(e.target.value)}
|
||||||
|
placeholder="Reason for rejection..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={rejectMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (rejectDialog !== null) {
|
||||||
|
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rejectMut.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,320 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
|
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
|
||||||
|
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
|
||||||
|
} from "lucide-react";
|
||||||
|
import { assignmentsService } from "@/services/assignments.service";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useBatches, useStudents } from "@/hooks/queries";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
|
||||||
|
|
||||||
|
interface CustomExam {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
|
||||||
|
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
|
||||||
|
{ key: "full_length", label: "Full length exams" },
|
||||||
|
{ key: "generate_different", label: "Generate different exams" },
|
||||||
|
{ key: "auto_release_results", label: "Auto release results" },
|
||||||
|
{ key: "auto_start", label: "Auto start exam" },
|
||||||
|
{ key: "official_exam", label: "Official Exam" },
|
||||||
|
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
|
||||||
|
switch (s) {
|
||||||
|
case "active": return "default";
|
||||||
|
case "planned": return "secondary";
|
||||||
|
case "past": return "outline";
|
||||||
|
case "start_expired": return "destructive";
|
||||||
|
case "archived": return "outline";
|
||||||
|
default: return "secondary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
name: string;
|
||||||
|
exam_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
start_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_date: string;
|
||||||
|
end_time: string;
|
||||||
|
assign_mode: "entity" | "batch" | "individual";
|
||||||
|
batch_ids: Set<number>;
|
||||||
|
student_ids: Set<number>;
|
||||||
|
full_length: boolean;
|
||||||
|
generate_different: boolean;
|
||||||
|
auto_release_results: boolean;
|
||||||
|
auto_start: boolean;
|
||||||
|
official_exam: boolean;
|
||||||
|
hide_assignee_details: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): FormState => ({
|
||||||
|
name: "",
|
||||||
|
exam_id: "",
|
||||||
|
entity_id: "",
|
||||||
|
start_date: "",
|
||||||
|
start_time: "09:00",
|
||||||
|
end_date: "",
|
||||||
|
end_time: "17:00",
|
||||||
|
assign_mode: "batch",
|
||||||
|
batch_ids: new Set(),
|
||||||
|
student_ids: new Set(),
|
||||||
|
full_length: true,
|
||||||
|
generate_different: false,
|
||||||
|
auto_release_results: false,
|
||||||
|
auto_start: false,
|
||||||
|
official_exam: false,
|
||||||
|
hide_assignee_details: false,
|
||||||
|
});
|
||||||
|
|
||||||
export default function AssignmentsPage() {
|
export default function AssignmentsPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
|
const [form, setForm] = useState<FormState>(emptyForm());
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const assignmentsQ = useAssignments({ size: 200 });
|
const schedulesQ = useQuery({
|
||||||
const createMut = useCreateAssignment();
|
queryKey: ["exam-schedules", stateFilter],
|
||||||
|
queryFn: () => assignmentsService.listSchedules(
|
||||||
|
stateFilter === "all" ? {} : { state: stateFilter }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const schedules = schedulesQ.data?.items ?? [];
|
||||||
|
|
||||||
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
|
const customExamsQ = useQuery({
|
||||||
const assignments = Array.isArray(items) ? items : [];
|
queryKey: ["custom-exams-for-assign"],
|
||||||
|
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
|
||||||
const q = search.toLowerCase();
|
});
|
||||||
const filtered = assignments.filter(a =>
|
const publishedExams = (customExamsQ.data?.items ?? []).filter(
|
||||||
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
|
(e) => e.status === "published" || e.status === "draft"
|
||||||
);
|
);
|
||||||
|
|
||||||
const loading = assignmentsQ.isLoading;
|
const entitiesQ = useQuery({
|
||||||
|
queryKey: ["entities-for-assign"],
|
||||||
|
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
|
||||||
|
});
|
||||||
|
const entities = entitiesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const batchesQ = useBatches({ size: 200 });
|
||||||
|
const batches = batchesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const studentsQ = useStudents({ size: 200 });
|
||||||
|
const students = studentsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const stateCounts = useMemo(() => {
|
||||||
|
const all = schedulesQ.data?.items ?? [];
|
||||||
|
const counts: Record<string, number> = { all: all.length };
|
||||||
|
for (const s of all) {
|
||||||
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [schedulesQ.data]);
|
||||||
|
|
||||||
|
const allSchedulesQ = useQuery({
|
||||||
|
queryKey: ["exam-schedules", "all"],
|
||||||
|
queryFn: () => assignmentsService.listSchedules({}),
|
||||||
|
enabled: stateFilter !== "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCounts = useMemo(() => {
|
||||||
|
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
|
||||||
|
const counts: Record<string, number> = { all: items.length };
|
||||||
|
for (const s of items) {
|
||||||
|
counts[s.state] = (counts[s.state] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [stateFilter, schedules, allSchedulesQ.data]);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setForm(emptyForm());
|
||||||
|
toast({ title: "Exam scheduled successfully" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
toast({ title: "Schedule deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveMut = useMutation({
|
||||||
|
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
|
||||||
|
toast({ title: "Schedule archived" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const filtered = schedules.filter(
|
||||||
|
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
createMut.mutate(
|
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
|
||||||
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
|
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
|
||||||
{
|
|
||||||
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
|
if (!form.name || !form.exam_id || !startDt || !endDt) {
|
||||||
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
|
||||||
},
|
return;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
createMut.mutate({
|
||||||
|
name: form.name,
|
||||||
|
exam_id: Number(form.exam_id),
|
||||||
|
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
|
||||||
|
start_date: startDt,
|
||||||
|
end_date: endDt,
|
||||||
|
assign_mode: form.assign_mode,
|
||||||
|
batch_ids: [...form.batch_ids],
|
||||||
|
student_ids: [...form.student_ids],
|
||||||
|
full_length: form.full_length,
|
||||||
|
generate_different: form.generate_different,
|
||||||
|
auto_release_results: form.auto_release_results,
|
||||||
|
auto_start: form.auto_start,
|
||||||
|
official_exam: form.official_exam,
|
||||||
|
hide_assignee_details: form.hide_assignee_details,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">Create and manage assignments.</p>
|
<Calendar className="h-6 w-6 text-primary" /> Assignments
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
|
||||||
<Plus className="h-4 w-4 mr-1" /> Create Assignment
|
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* State tabs */}
|
||||||
|
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
|
||||||
|
{STATE_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||||||
|
stateFilter === tab.key
|
||||||
|
? "bg-white text-primary shadow-sm"
|
||||||
|
: "text-primary/70 hover:text-primary"
|
||||||
|
}`}
|
||||||
|
onClick={() => setStateFilter(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
|
||||||
|
({totalCounts[tab.key] ?? 0})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative max-w-sm">
|
<div className="relative max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{schedulesQ.isLoading ? (
|
||||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="p-12 text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="font-medium">No exam schedules found</p>
|
||||||
|
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="border-0 shadow-sm">
|
<Card className="border-0 shadow-sm">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Title</TableHead>
|
<TableHead>Schedule Name</TableHead>
|
||||||
|
<TableHead>Exam</TableHead>
|
||||||
<TableHead>Entity</TableHead>
|
<TableHead>Entity</TableHead>
|
||||||
<TableHead>Start</TableHead>
|
<TableHead>Start</TableHead>
|
||||||
<TableHead>End</TableHead>
|
<TableHead>End</TableHead>
|
||||||
<TableHead>State</TableHead>
|
<TableHead>State</TableHead>
|
||||||
<TableHead>Assignees</TableHead>
|
<TableHead>Assignees</TableHead>
|
||||||
<TableHead>Completed</TableHead>
|
<TableHead>Completed</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.length === 0 && (
|
{filtered.map((s) => (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
|
<TableRow key={s.id}>
|
||||||
)}
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
{filtered.map((a) => (
|
<TableCell>{s.exam_title}</TableCell>
|
||||||
<TableRow key={a.id}>
|
<TableCell>{s.entity_name || "—"}</TableCell>
|
||||||
<TableCell className="font-medium">{a.title}</TableCell>
|
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
|
||||||
<TableCell>{a.entity_name || "—"}</TableCell>
|
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
|
||||||
<TableCell>{a.start_date || "—"}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{a.end_date || "—"}</TableCell>
|
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
|
||||||
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
|
</TableCell>
|
||||||
<TableCell>{a.assignee_count ?? 0}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{a.completed_count ?? 0}</TableCell>
|
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{s.completed_count}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{s.state !== "archived" && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
|
||||||
|
onClick={() => archiveMut.mutate(s.id)}>
|
||||||
|
<Archive className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
|
||||||
|
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -94,21 +323,169 @@ export default function AssignmentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Schedule Dialog */}
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<div className="space-y-4">
|
<DialogTitle>Schedule Exam</DialogTitle>
|
||||||
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
|
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
|
||||||
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
|
</DialogHeader>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
|
<div className="space-y-5 py-2">
|
||||||
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
|
{/* Basic info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Schedule Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Exam <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{publishedExams.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>
|
||||||
|
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{publishedExams.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No exams available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/Time */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Start Time</Label>
|
||||||
|
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">End Time</Label>
|
||||||
|
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options checkboxes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Exam Options</Label>
|
||||||
|
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
{OPTION_FIELDS.map((opt) => (
|
||||||
|
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={form[opt.key] as boolean}
|
||||||
|
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment target */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">Assign To</Label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Assignment Mode</Label>
|
||||||
|
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="entity">Entire Entity</SelectItem>
|
||||||
|
<SelectItem value="batch">Class / Batch</SelectItem>
|
||||||
|
<SelectItem value="individual">Individual Students</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Entity</Label>
|
||||||
|
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{entities.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No entities</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Batch selection */}
|
||||||
|
{form.assign_mode === "batch" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Select Classes</Label>
|
||||||
|
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{batches.map((b) => (
|
||||||
|
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.batch_ids.has(b.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((p) => {
|
||||||
|
const next = new Set(p.batch_ids);
|
||||||
|
checked ? next.add(b.id) : next.delete(b.id);
|
||||||
|
return { ...p, batch_ids: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{b.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Individual student selection */}
|
||||||
|
{form.assign_mode === "individual" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Select Students</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{students.map((s) => (
|
||||||
|
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.student_ids.has(s.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((p) => {
|
||||||
|
const next = new Set(p.student_ids);
|
||||||
|
checked ? next.add(s.id) : next.delete(s.id);
|
||||||
|
return { ...p, student_ids: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
|
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
|
||||||
{createMut.isPending ? "Creating..." : "Create"}
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -52,12 +53,17 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Settings2,
|
Settings2,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
GraduationCap,
|
||||||
|
Dumbbell,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import { generationService } from "@/services/generation.service";
|
import { generationService } from "@/services/generation.service";
|
||||||
import { mediaService, type Avatar } from "@/services/media.service";
|
import { mediaService, type Avatar } from "@/services/media.service";
|
||||||
import { examsService } from "@/services/exams.service";
|
import { examsService } from "@/services/exams.service";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { ExamStructureConfig } from "@/types";
|
||||||
|
|
||||||
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
||||||
|
|
||||||
@@ -222,6 +228,7 @@ interface ModuleState {
|
|||||||
approvalWorkflow: string;
|
approvalWorkflow: string;
|
||||||
rubricGroup: string;
|
rubricGroup: string;
|
||||||
rubricCriteria: string;
|
rubricCriteria: string;
|
||||||
|
rubricId: string;
|
||||||
totalMarks: number;
|
totalMarks: number;
|
||||||
gradingSystem: string;
|
gradingSystem: string;
|
||||||
shuffling: boolean;
|
shuffling: boolean;
|
||||||
@@ -273,6 +280,7 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
|
|||||||
approvalWorkflow: "",
|
approvalWorkflow: "",
|
||||||
rubricGroup: "",
|
rubricGroup: "",
|
||||||
rubricCriteria: "",
|
rubricCriteria: "",
|
||||||
|
rubricId: "",
|
||||||
totalMarks: 0,
|
totalMarks: 0,
|
||||||
gradingSystem: "",
|
gradingSystem: "",
|
||||||
shuffling: false,
|
shuffling: false,
|
||||||
@@ -288,9 +296,17 @@ function defaultModuleState(mod: ModuleKey): ModuleState {
|
|||||||
|
|
||||||
export default function GenerationPage() {
|
export default function GenerationPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [examMode, setExamMode] = useState<"official" | "practice">("official");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [examLabel, setExamLabel] = useState("");
|
const [examLabel, setExamLabel] = useState("");
|
||||||
const [examStructure, setExamStructure] = useState("");
|
const [examStructure, setExamStructure] = useState("");
|
||||||
|
const [newStructureDialog, setNewStructureDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
name: string;
|
||||||
|
modules: Set<ModuleKey>;
|
||||||
|
}>({ open: false, name: "", modules: new Set() });
|
||||||
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
||||||
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
||||||
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
||||||
@@ -352,6 +368,47 @@ export default function GenerationPage() {
|
|||||||
});
|
});
|
||||||
const structures = structuresQ.data?.items ?? [];
|
const structures = structuresQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const rubricsQ = useQuery({
|
||||||
|
queryKey: ["rubrics"],
|
||||||
|
queryFn: () => examsService.listRubrics({}),
|
||||||
|
});
|
||||||
|
const rubrics = rubricsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const rubricGroupsQ = useQuery({
|
||||||
|
queryKey: ["rubric-groups"],
|
||||||
|
queryFn: () => examsService.listRubricGroups({}),
|
||||||
|
});
|
||||||
|
const rubricGroups = rubricGroupsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const entitiesQ = useQuery({
|
||||||
|
queryKey: ["entities"],
|
||||||
|
queryFn: () => api.get<{ items: { id: number; name: string; code: string; type: string }[] }>("/entities"),
|
||||||
|
});
|
||||||
|
const entities = entitiesQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const workflowsQ = useQuery({
|
||||||
|
queryKey: ["approval-workflows"],
|
||||||
|
queryFn: () => api.get<{ items: { id: number; name: string; type: string }[] }>("/approval-workflows"),
|
||||||
|
});
|
||||||
|
const approvalWorkflows = workflowsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const selectedStructure = structures.find((s) => String(s.id) === examStructure);
|
||||||
|
const selectedStructureConfig = (selectedStructure?.config && typeof selectedStructure.config === "object" ? selectedStructure.config : null) as ExamStructureConfig | null;
|
||||||
|
const isOfficialLocked = examMode === "official" && !!selectedStructure;
|
||||||
|
|
||||||
|
const createStructureMut = useMutation({
|
||||||
|
mutationFn: (data: { name: string; modules: string[] }) =>
|
||||||
|
examsService.createStructure(data),
|
||||||
|
onSuccess: (created) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
|
||||||
|
setExamStructure(String(created.id));
|
||||||
|
setNewStructureDialog({ open: false, name: "", modules: new Set() });
|
||||||
|
toast({ title: "Structure created", description: `"${created.name}" has been added.` });
|
||||||
|
},
|
||||||
|
onError: (err: Error) =>
|
||||||
|
toast({ variant: "destructive", title: "Failed to create structure", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
|
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
|
||||||
return moduleStates[mod] ?? defaultModuleState(mod);
|
return moduleStates[mod] ?? defaultModuleState(mod);
|
||||||
}, [moduleStates]);
|
}, [moduleStates]);
|
||||||
@@ -579,13 +636,44 @@ export default function GenerationPage() {
|
|||||||
accessType: st.accessType,
|
accessType: st.accessType,
|
||||||
shuffling: st.shuffling,
|
shuffling: st.shuffling,
|
||||||
gradingSystem: st.gradingSystem,
|
gradingSystem: st.gradingSystem,
|
||||||
passages: mod === "reading" ? st.passages.map((p) => ({ text: p.text, category: p.category, type: p.type, exercises: p.exercises })) : undefined,
|
rubricId: st.rubricId,
|
||||||
sections: mod === "listening" ? st.listeningSections.map((s) => ({ type: s.type, context: s.context, audioUrl: s.audioUrl })) : undefined,
|
entity: st.entity,
|
||||||
tasks: mod === "writing" ? st.writingTasks.map((t) => ({ instructions: t.instructions, wordLimit: t.wordLimit, marks: t.marks })) : undefined,
|
approvalWorkflow: st.approvalWorkflow,
|
||||||
parts: mod === "speaking" ? st.speakingParts.map((p) => ({ type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks })) : undefined,
|
totalMarks: st.totalMarks,
|
||||||
|
passages: mod === "reading" ? st.passages.map((p) => ({
|
||||||
|
text: p.text, category: p.category, type: p.type,
|
||||||
|
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
|
||||||
|
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||||
|
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||||
|
instructions: ex.instructions, marks: ex.marks || 1,
|
||||||
|
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
|
||||||
|
})),
|
||||||
|
})) : undefined,
|
||||||
|
sections: mod === "listening" ? st.listeningSections.map((s) => ({
|
||||||
|
type: s.type, context: s.context, audioUrl: s.audioUrl,
|
||||||
|
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
|
||||||
|
type: ex.type, prompt: ex.prompt, options: ex.options,
|
||||||
|
correct_answer: ex.correct_answer, explanation: ex.explanation,
|
||||||
|
instructions: ex.instructions, marks: ex.marks || 1,
|
||||||
|
difficulty: ex.difficulty || st.difficulty?.[0] || "B2",
|
||||||
|
})),
|
||||||
|
})) : undefined,
|
||||||
|
tasks: mod === "writing" ? st.writingTasks.map((t) => ({
|
||||||
|
instructions: t.instructions, wordLimit: t.wordLimit,
|
||||||
|
marks: t.marks, category: t.category, type: t.type,
|
||||||
|
})) : undefined,
|
||||||
|
parts: mod === "speaking" ? st.speakingParts.map((p) => ({
|
||||||
|
type: p.type, script: p.script, videoUrl: p.videoUrl, marks: p.marks,
|
||||||
|
})) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
|
return generationService.submitExam({
|
||||||
|
title, label: examLabel,
|
||||||
|
exam_mode: examMode,
|
||||||
|
structure_id: examStructure ? Number(examStructure) : undefined,
|
||||||
|
modules: modulesPayload,
|
||||||
|
skip_approval: skipApproval,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (res) => toast({
|
onSuccess: (res) => toast({
|
||||||
title: "Exam submitted successfully",
|
title: "Exam submitted successfully",
|
||||||
@@ -599,27 +687,6 @@ export default function GenerationPage() {
|
|||||||
generateAudioMut.isPending || generateWritingMut.isPending ||
|
generateAudioMut.isPending || generateWritingMut.isPending ||
|
||||||
generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending;
|
generateSpeakingMut.isPending || generateVideoMut.isPending || submitMut.isPending;
|
||||||
|
|
||||||
const renderDifficultyTags = (mod: ModuleKey) => {
|
|
||||||
const st = getModuleState(mod);
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Difficulty</Label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{st.difficulty.map((d) => (
|
|
||||||
<Badge key={d} variant="secondary" className="text-xs gap-1">
|
|
||||||
{d}
|
|
||||||
<button onClick={() => updateModuleState(mod, { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState(mod, { difficulty: [...st.difficulty, v] }); }}>
|
|
||||||
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
|
|
||||||
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCommonConfig = (mod: ModuleKey) => {
|
const renderCommonConfig = (mod: ModuleKey) => {
|
||||||
const st = getModuleState(mod);
|
const st = getModuleState(mod);
|
||||||
return (
|
return (
|
||||||
@@ -628,7 +695,34 @@ export default function GenerationPage() {
|
|||||||
<Label className="text-xs">Timer (minutes)</Label>
|
<Label className="text-xs">Timer (minutes)</Label>
|
||||||
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState(mod, { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
||||||
</div>
|
</div>
|
||||||
{renderDifficultyTags(mod)}
|
{examMode === "official" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Rubric</Label>
|
||||||
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rubrics.length > 0 && (
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||||
|
)}
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{rubricGroups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t my-1" />
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rubricGroups.map((g) => (
|
||||||
|
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Access Type</Label>
|
<Label className="text-xs">Access Type</Label>
|
||||||
<Select value={st.accessType} onValueChange={(v) => updateModuleState(mod, { accessType: v })}>
|
<Select value={st.accessType} onValueChange={(v) => updateModuleState(mod, { accessType: v })}>
|
||||||
@@ -643,21 +737,36 @@ export default function GenerationPage() {
|
|||||||
<Label className="text-xs">Entities</Label>
|
<Label className="text-xs">Entities</Label>
|
||||||
<Select value={st.entity} onValueChange={(v) => updateModuleState(mod, { entity: v })}>
|
<Select value={st.entity} onValueChange={(v) => updateModuleState(mod, { entity: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Approval Workflow</Label>
|
<Label className="text-xs">Approval Workflow</Label>
|
||||||
<Select value={st.approvalWorkflow} onValueChange={(v) => updateModuleState(mod, { approvalWorkflow: v })}>
|
<Select value={st.approvalWorkflow} onValueChange={(v) => updateModuleState(mod, { approvalWorkflow: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="default">Default</SelectItem><SelectItem value="none">None</SelectItem></SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{approvalWorkflows.map((w) => (
|
||||||
|
<SelectItem key={w.id} value={String(w.id)}>{w.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Grading System</Label>
|
<Label className="text-xs">Grading System</Label>
|
||||||
<Select value={st.gradingSystem} onValueChange={(v) => updateModuleState(mod, { gradingSystem: v })}>
|
<Select value={st.gradingSystem} onValueChange={(v) => updateModuleState(mod, { gradingSystem: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="System Select" /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="System Select" /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="ielts">IELTS Band</SelectItem><SelectItem value="percentage">Percentage</SelectItem></SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="ielts">IELTS Band (1-9)</SelectItem>
|
||||||
|
<SelectItem value="percentage">Percentage (0-100%)</SelectItem>
|
||||||
|
<SelectItem value="pass_fail">Pass / Fail</SelectItem>
|
||||||
|
<SelectItem value="cefr">CEFR Level (A1-C2)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1589,23 +1698,6 @@ export default function GenerationPage() {
|
|||||||
const renderLevelModule = () => {
|
const renderLevelModule = () => {
|
||||||
if (activeModule !== "level") return null;
|
if (activeModule !== "level") return null;
|
||||||
const st = getModuleState("level");
|
const st = getModuleState("level");
|
||||||
const renderLevelDifficulty = () => (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">Difficulty</Label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{st.difficulty.map((d) => (
|
|
||||||
<Badge key={d} variant="secondary" className="text-xs gap-1">
|
|
||||||
{d}
|
|
||||||
<button onClick={() => updateModuleState("level", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("level", { difficulty: [...st.difficulty, v] }); }}>
|
|
||||||
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
|
|
||||||
<SelectContent>{CEFR_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||||
@@ -1613,7 +1705,34 @@ export default function GenerationPage() {
|
|||||||
<Label className="text-xs">Timer (minutes)</Label>
|
<Label className="text-xs">Timer (minutes)</Label>
|
||||||
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("level", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("level", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
||||||
</div>
|
</div>
|
||||||
{renderLevelDifficulty()}
|
{examMode === "official" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Rubric</Label>
|
||||||
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("level", { rubricId: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rubrics.length > 0 && (
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||||
|
)}
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{rubricGroups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t my-1" />
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rubricGroups.map((g) => (
|
||||||
|
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Number of Parts</Label>
|
<Label className="text-xs">Number of Parts</Label>
|
||||||
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
|
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
|
||||||
@@ -1638,7 +1757,12 @@ export default function GenerationPage() {
|
|||||||
<Label className="text-xs">Entities</Label>
|
<Label className="text-xs">Entities</Label>
|
||||||
<Select value={st.entity} onValueChange={(v) => updateModuleState("level", { entity: v })}>
|
<Select value={st.entity} onValueChange={(v) => updateModuleState("level", { entity: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
||||||
@@ -1797,21 +1921,34 @@ export default function GenerationPage() {
|
|||||||
<Label className="text-xs">Timer (minutes)</Label>
|
<Label className="text-xs">Timer (minutes)</Label>
|
||||||
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("industry", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
<Input type="number" value={st.timer} min={1} onChange={(e) => updateModuleState("industry", { timer: Number(e.target.value) || 1 })} className="h-8 text-xs" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
{examMode === "official" && (
|
||||||
<Label className="text-xs">Difficulty</Label>
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap gap-1">
|
<Label className="text-xs">Rubric</Label>
|
||||||
{st.difficulty.map((d) => (
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState("industry", { rubricId: v })}>
|
||||||
<Badge key={d} variant="secondary" className="text-xs gap-1">
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||||
{d}
|
<SelectContent>
|
||||||
<button onClick={() => updateModuleState("industry", { difficulty: st.difficulty.filter((x) => x !== d) })} className="ml-0.5 hover:text-destructive"><X className="h-3 w-3" /></button>
|
{rubrics.length > 0 && (
|
||||||
</Badge>
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||||
))}
|
)}
|
||||||
<Select onValueChange={(v) => { if (!st.difficulty.includes(v)) updateModuleState("industry", { difficulty: [...st.difficulty, v] }); }}>
|
{rubrics.map((r) => (
|
||||||
<SelectTrigger className="h-6 w-16 text-xs"><Plus className="h-3 w-3" /></SelectTrigger>
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||||
<SelectContent>{INDUSTRY_DIFFICULTY_LEVELS.filter((l) => !st.difficulty.includes(l)).map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
))}
|
||||||
|
{rubricGroups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t my-1" />
|
||||||
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rubricGroups.map((g) => (
|
||||||
|
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||||
|
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Number of Parts</Label>
|
<Label className="text-xs">Number of Parts</Label>
|
||||||
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
|
<Input type="number" value={st.numberOfParts} min={1} max={5} className="h-8 text-xs"
|
||||||
@@ -1836,7 +1973,12 @@ export default function GenerationPage() {
|
|||||||
<Label className="text-xs">Entities</Label>
|
<Label className="text-xs">Entities</Label>
|
||||||
<Select value={st.entity} onValueChange={(v) => updateModuleState("industry", { entity: v })}>
|
<Select value={st.entity} onValueChange={(v) => updateModuleState("industry", { entity: v })}>
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
<SelectContent><SelectItem value="none">None</SelectItem></SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
<div className="flex items-center gap-2 col-span-2 md:col-span-1">
|
||||||
@@ -1946,6 +2088,110 @@ export default function GenerationPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStructureSelect = (val: string) => {
|
||||||
|
setExamStructure(val);
|
||||||
|
const s = structures.find((st) => String(st.id) === val);
|
||||||
|
if (s) {
|
||||||
|
const mods = (Array.isArray(s.modules) ? s.modules : []) as string[];
|
||||||
|
if (mods.length) {
|
||||||
|
const next = new Set<ModuleKey>();
|
||||||
|
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
|
||||||
|
setSelectedModules(next);
|
||||||
|
if (next.size > 0) setActiveModule([...next][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeSwitch = (mode: "official" | "practice") => {
|
||||||
|
setExamMode(mode);
|
||||||
|
if (mode === "practice") {
|
||||||
|
setExamStructure("");
|
||||||
|
}
|
||||||
|
setSelectedModules(new Set());
|
||||||
|
setActiveModule(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOfficialStructurePreview = () => {
|
||||||
|
if (!selectedStructure || !selectedStructureConfig) return null;
|
||||||
|
const cfg = selectedStructureConfig;
|
||||||
|
const mods = (Array.isArray(selectedStructure.modules) ? selectedStructure.modules : []) as string[];
|
||||||
|
return (
|
||||||
|
<Card className="border bg-muted/30">
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">{selectedStructure.name}</span>
|
||||||
|
{cfg.exam_type && <Badge variant="outline" className="capitalize text-xs">{cfg.exam_type}</Badge>}
|
||||||
|
{selectedStructure.industry && <Badge variant="secondary" className="text-xs">{selectedStructure.industry}</Badge>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">Read-only structure</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{mods.map((m) => (
|
||||||
|
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-1">
|
||||||
|
{cfg.listening && mods.includes("listening") && (
|
||||||
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Headphones className="h-3.5 w-3.5 text-teal-600" /> Listening</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{cfg.listening.parts?.length ?? 0} parts · {cfg.listening.total_questions ?? 0} questions</p>
|
||||||
|
{cfg.listening.parts?.map((p, i) => (
|
||||||
|
<p key={i} className="text-xs text-muted-foreground">P{i + 1}: {p.label}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfg.reading && mods.includes("reading") && (
|
||||||
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium"><BookOpen className="h-3.5 w-3.5 text-blue-600" /> Reading</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{cfg.reading.passages?.length ?? 0} passages · {cfg.reading.total_questions ?? 0} questions</p>
|
||||||
|
{cfg.reading.passages?.map((p, i) => (
|
||||||
|
<p key={i} className="text-xs text-muted-foreground">{cfg.exam_type === "academic" ? "Passage" : "Section"} {i + 1}: {p.style}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfg.writing && mods.includes("writing") && (
|
||||||
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium"><PenTool className="h-3.5 w-3.5 text-orange-600" /> Writing</div>
|
||||||
|
{cfg.writing.tasks?.map((t, i) => (
|
||||||
|
<p key={i} className="text-xs text-muted-foreground">{t.label || `Task ${i + 1}`}: {t.type} (min {t.min_words} words)</p>
|
||||||
|
))}
|
||||||
|
{!cfg.writing.tasks?.length && cfg.writing.task1 && (
|
||||||
|
<p className="text-xs text-muted-foreground">Task 1: {cfg.writing.task1.type} · Task 2: {cfg.writing.task2?.type}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfg.speaking && mods.includes("speaking") && (
|
||||||
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Mic className="h-3.5 w-3.5 text-purple-600" /> Speaking</div>
|
||||||
|
{cfg.speaking.parts?.map((p, i) => (
|
||||||
|
<p key={i} className="text-xs text-muted-foreground">Part {i + 1}: {p.label} ({p.duration_min}-{p.duration_max} min)</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfg.level && mods.includes("level") && (
|
||||||
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Layers className="h-3.5 w-3.5 text-indigo-600" /> Level</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{Object.values(cfg.level.exercise_types || {}).reduce((s, v) => s + v, 0)} total questions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfg.industry && mods.includes("industry") && (
|
||||||
|
<div className="rounded-md border bg-background p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium"><Briefcase className="h-3.5 w-3.5 text-amber-700" /> Industry</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Difficulty: {cfg.industry.difficulty}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1956,9 +2202,34 @@ export default function GenerationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div className="flex gap-2 border-b pb-0">
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||||
|
examMode === "official"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleModeSwitch("official")}
|
||||||
|
>
|
||||||
|
<GraduationCap className="h-4 w-4" /> Official Exam
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||||
|
examMode === "practice"
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
onClick={() => handleModeSwitch("practice")}
|
||||||
|
>
|
||||||
|
<Dumbbell className="h-4 w-4" /> Practice Exam
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="generation" variant="recommendation" />
|
<AiTipBanner context="generation" variant="recommendation" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
{/* Title, Label, Structure row */}
|
||||||
|
<div className={`grid grid-cols-1 gap-4 ${examMode === "official" ? "md:grid-cols-3" : "md:grid-cols-2"}`}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Title <span className="text-destructive">*</span></Label>
|
<Label>Title <span className="text-destructive">*</span></Label>
|
||||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Insert title" />
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Insert title" />
|
||||||
@@ -1967,52 +2238,87 @@ export default function GenerationPage() {
|
|||||||
<Label>Exam Label</Label>
|
<Label>Exam Label</Label>
|
||||||
<Input value={examLabel} onChange={(e) => setExamLabel(e.target.value)} placeholder="Exam Label" />
|
<Input value={examLabel} onChange={(e) => setExamLabel(e.target.value)} placeholder="Exam Label" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
{examMode === "official" && (
|
||||||
<Label>Exam Structure</Label>
|
<div className="space-y-1">
|
||||||
<Select value={examStructure} onValueChange={(val) => {
|
<Label>Exam Structure <span className="text-destructive">*</span></Label>
|
||||||
setExamStructure(val);
|
<Select value={examStructure} onValueChange={(val) => {
|
||||||
const s = structures.find((st) => String(st.id) === val);
|
if (val === "__add_new__") {
|
||||||
if (s) {
|
navigate("/admin/exam-structures");
|
||||||
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
|
return;
|
||||||
if (Array.isArray(mods) && mods.length) {
|
|
||||||
const next = new Set<ModuleKey>();
|
|
||||||
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
|
|
||||||
setSelectedModules(next);
|
|
||||||
if (next.size > 0) setActiveModule([...next][0]);
|
|
||||||
}
|
}
|
||||||
}
|
handleStructureSelect(val);
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{structures.map((s) => (
|
{structures.map((s) => (
|
||||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
{structures.length === 0 && (
|
{structures.length === 0 && (
|
||||||
<SelectItem value="_none" disabled>No structures available</SelectItem>
|
<SelectItem value="_none" disabled>No structures available</SelectItem>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
<div className="border-t my-1" />
|
||||||
</Select>
|
<SelectItem value="__add_new__" className="text-primary font-medium">
|
||||||
</div>
|
<span className="flex items-center gap-1.5"><Plus className="h-3.5 w-3.5" /> Add New</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Official mode: read-only structure preview */}
|
||||||
<p className="text-sm text-muted-foreground mb-3">Please select which subjects the exam is about</p>
|
{examMode === "official" && selectedStructure && renderOfficialStructurePreview()}
|
||||||
<div className="flex flex-wrap gap-3">
|
{examMode === "official" && !selectedStructure && (
|
||||||
{MODULES.map((m) => (
|
<Card className="border-dashed">
|
||||||
<button key={m.key}
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
|
<GraduationCap className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
|
<p className="text-sm">Select an Exam Structure above to view its configuration and start generating.</p>
|
||||||
}`}
|
</CardContent>
|
||||||
onClick={() => toggleModule(m.key)}>
|
</Card>
|
||||||
<span className={m.color}>{m.icon}</span>
|
)}
|
||||||
<span className="text-sm font-medium">{m.label}</span>
|
|
||||||
{selectedModules.has(m.key) && (
|
{/* Practice mode: free module selection */}
|
||||||
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
|
{examMode === "practice" && (
|
||||||
)}
|
<div>
|
||||||
</button>
|
<p className="text-sm text-muted-foreground mb-3">Select which modules you want to practice</p>
|
||||||
))}
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{MODULES.map((m) => (
|
||||||
|
<button key={m.key}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
|
||||||
|
selectedModules.has(m.key) ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleModule(m.key)}>
|
||||||
|
<span className={m.color}>{m.icon}</span>
|
||||||
|
<span className="text-sm font-medium">{m.label}</span>
|
||||||
|
{selectedModules.has(m.key) && (
|
||||||
|
<Badge variant="default" className="ml-1 h-5 text-[10px]">Select</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Official mode: module selector from structure's modules (locked, non-toggleable) */}
|
||||||
|
{examMode === "official" && isOfficialLocked && selectedModules.size > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3 flex items-center gap-1.5">
|
||||||
|
<Lock className="h-3.5 w-3.5" /> Modules defined by structure — select one to configure
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{MODULES.filter((m) => selectedModules.has(m.key)).map((m) => (
|
||||||
|
<button key={m.key}
|
||||||
|
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-3 transition-all ${
|
||||||
|
activeModule === m.key ? `${m.bgColor} border-current ${m.color} shadow-sm` : "border-muted bg-background hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveModule(m.key)}>
|
||||||
|
<span className={m.color}>{m.icon}</span>
|
||||||
|
<span className="text-sm font-medium">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeModule && selectedModules.has(activeModule) && (
|
{activeModule && selectedModules.has(activeModule) && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -2530,6 +2836,67 @@ export default function GenerationPage() {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={newStructureDialog.open} onOpenChange={(open) => {
|
||||||
|
if (!open) setNewStructureDialog({ open: false, name: "", modules: new Set() });
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Exam Structure</DialogTitle>
|
||||||
|
<DialogDescription>Create a new exam structure with selected modules.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
value={newStructureDialog.name}
|
||||||
|
onChange={(e) => setNewStructureDialog((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="e.g. Business English Assessment"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Modules</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{MODULES.map((m) => (
|
||||||
|
<label key={m.key} className={`flex items-center gap-2 rounded-lg border px-3 py-2 cursor-pointer transition-all ${
|
||||||
|
newStructureDialog.modules.has(m.key) ? `${m.bgColor} ${m.color}` : "hover:bg-muted/50"
|
||||||
|
}`}>
|
||||||
|
<Checkbox
|
||||||
|
checked={newStructureDialog.modules.has(m.key)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setNewStructureDialog((prev) => {
|
||||||
|
const next = new Set(prev.modules);
|
||||||
|
if (checked) next.add(m.key); else next.delete(m.key);
|
||||||
|
return { ...prev, modules: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{m.icon}
|
||||||
|
<span className="text-sm font-medium">{m.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNewStructureDialog({ open: false, name: "", modules: new Set() })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!newStructureDialog.name.trim() || newStructureDialog.modules.size === 0 || createStructureMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
createStructureMut.mutate({
|
||||||
|
name: newStructureDialog.name.trim(),
|
||||||
|
modules: [...newStructureDialog.modules],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{createStructureMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Creating...</> : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,527 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import {
|
||||||
import { Search, Plus, Loader2 } from "lucide-react";
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
import { examsService } from "@/services/exams.service";
|
import { examsService } from "@/services/exams.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface RubricItem {
|
interface RubricItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
levels: string[];
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
criteria: number;
|
criteria: number;
|
||||||
|
criteria_text: string;
|
||||||
|
levels: string[];
|
||||||
created: string;
|
created: string;
|
||||||
skill?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_RUBRICS: RubricItem[] = [
|
interface CriterionEntry {
|
||||||
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
name: string;
|
||||||
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
weight: number;
|
||||||
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
descriptors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_OPTIONS = [
|
||||||
|
{ value: "writing", label: "Writing" },
|
||||||
|
{ value: "speaking", label: "Speaking" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rubricGroups = [
|
const EXAM_TYPE_OPTIONS = [
|
||||||
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
|
{ value: "academic", label: "Academic" },
|
||||||
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
|
{ value: "general_training", label: "General Training" },
|
||||||
|
{ value: "general_english", label: "General English" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ALL_CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||||
|
|
||||||
|
interface RubricFormState {
|
||||||
|
name: string;
|
||||||
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
criteria: CriterionEntry[];
|
||||||
|
levels: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
|
||||||
|
name: "",
|
||||||
|
weight: 0,
|
||||||
|
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyForm = (): RubricFormState => ({
|
||||||
|
name: "",
|
||||||
|
skill: "writing",
|
||||||
|
exam_type: "academic",
|
||||||
|
criteria: [],
|
||||||
|
levels: new Set(ALL_CEFR_LEVELS),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseCriteriaText(text: string, levels: string[]): CriterionEntry[] {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const mapStructured = (arr: unknown[]): CriterionEntry[] =>
|
||||||
|
arr.map((c) => {
|
||||||
|
const obj = c as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: String(obj.name || ""),
|
||||||
|
weight: Number(obj.weight || 0),
|
||||||
|
descriptors:
|
||||||
|
typeof obj.descriptors === "object" && obj.descriptors !== null
|
||||||
|
? Object.fromEntries(levels.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")]))
|
||||||
|
: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
// Handle {"criteria": [...]} wrapper (from AI or previous saves)
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.criteria)) {
|
||||||
|
return mapStructured(parsed.criteria);
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
if (parsed.length > 0 && typeof parsed[0] === "object" && parsed[0] !== null && "name" in parsed[0]) {
|
||||||
|
return mapStructured(parsed);
|
||||||
|
}
|
||||||
|
return parsed.filter(Boolean).map((name) => ({
|
||||||
|
name: String(name),
|
||||||
|
weight: Math.round(100 / parsed.length),
|
||||||
|
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
|
||||||
|
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||||
|
if (lines.length === 0) return [];
|
||||||
|
return lines.map((name) => ({
|
||||||
|
name,
|
||||||
|
weight: Math.round(100 / lines.length),
|
||||||
|
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupFormState {
|
||||||
|
name: string;
|
||||||
|
rubric_ids: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyGroupForm = (): GroupFormState => ({ name: "", rubric_ids: new Set() });
|
||||||
|
|
||||||
export default function RubricsPage() {
|
export default function RubricsPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState<RubricFormState>(emptyForm());
|
||||||
|
|
||||||
|
const [editTarget, setEditTarget] = useState<RubricItem | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState<RubricFormState>(emptyForm());
|
||||||
|
|
||||||
|
const [groupCreateOpen, setGroupCreateOpen] = useState(false);
|
||||||
|
const [groupCreateForm, setGroupCreateForm] = useState<GroupFormState>(emptyGroupForm());
|
||||||
|
const [groupEditTarget, setGroupEditTarget] = useState<{ id: number; name: string; rubric_ids: number[] } | null>(null);
|
||||||
|
const [groupEditForm, setGroupEditForm] = useState<GroupFormState>(emptyGroupForm());
|
||||||
|
|
||||||
const rubricsQ = useQuery({
|
const rubricsQ = useQuery({
|
||||||
queryKey: ["rubrics"],
|
queryKey: ["rubrics"],
|
||||||
queryFn: () => examsService.listRubrics({}),
|
queryFn: () => examsService.listRubrics({}),
|
||||||
});
|
});
|
||||||
const backendRubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
||||||
const rubrics = backendRubrics.length > 0 ? backendRubrics : FALLBACK_RUBRICS;
|
|
||||||
|
const rubricGroupsQ = useQuery({
|
||||||
|
queryKey: ["rubric-groups"],
|
||||||
|
queryFn: () => examsService.listRubricGroups({}),
|
||||||
|
});
|
||||||
|
const rubricGroups = rubricGroupsQ.data?.items ?? [];
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => examsService.createRubric(data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
setCreateForm(emptyForm());
|
||||||
|
toast({ title: "Rubric created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Create failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
|
||||||
|
examsService.updateRubric(id, data as never),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
setEditTarget(null);
|
||||||
|
toast({ title: "Rubric updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteRubric(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
|
||||||
|
toast({ title: "Rubric deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Delete failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupMut = useMutation({
|
||||||
|
mutationFn: (data: { name: string; rubric_ids: number[] }) => examsService.createRubricGroup(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
setGroupCreateOpen(false);
|
||||||
|
setGroupCreateForm(emptyGroupForm());
|
||||||
|
toast({ title: "Rubric group created" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Create group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateGroupMut = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: { name?: string; rubric_ids?: number[] } }) =>
|
||||||
|
examsService.updateRubricGroup(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
setGroupEditTarget(null);
|
||||||
|
toast({ title: "Rubric group updated" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Update group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteGroupMut = useMutation({
|
||||||
|
mutationFn: (id: number) => examsService.deleteRubricGroup(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
|
||||||
|
toast({ title: "Rubric group deleted" });
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast({ variant: "destructive", title: "Delete group failed", description: err.message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [suggesting, setSuggesting] = useState(false);
|
||||||
|
|
||||||
|
const suggestCriteria = async (
|
||||||
|
form: RubricFormState,
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
) => {
|
||||||
|
setSuggesting(true);
|
||||||
|
try {
|
||||||
|
const result = await examsService.suggestRubricCriteria({
|
||||||
|
name: form.name,
|
||||||
|
skill: form.skill,
|
||||||
|
exam_type: form.exam_type,
|
||||||
|
levels: [...form.levels],
|
||||||
|
});
|
||||||
|
if (result.criteria && Array.isArray(result.criteria) && result.criteria.length > 0) {
|
||||||
|
const levelsArr = [...form.levels];
|
||||||
|
const entries: CriterionEntry[] = result.criteria.map((c: unknown) => {
|
||||||
|
if (typeof c === "object" && c !== null) {
|
||||||
|
const obj = c as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: String(obj.name || ""),
|
||||||
|
weight: Number(obj.weight || 0),
|
||||||
|
descriptors: typeof obj.descriptors === "object" && obj.descriptors !== null
|
||||||
|
? Object.fromEntries(
|
||||||
|
levelsArr.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")])
|
||||||
|
)
|
||||||
|
: Object.fromEntries(levelsArr.map((l) => [l, ""])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: String(c), weight: 0, descriptors: Object.fromEntries(levelsArr.map((l) => [l, ""])) };
|
||||||
|
});
|
||||||
|
setForm((f) => ({ ...f, criteria: entries }));
|
||||||
|
toast({ title: "Criteria generated", description: `${entries.length} criteria with band descriptors created by AI.` });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({ variant: "destructive", title: "AI suggestion failed", description: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setSuggesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (r: RubricItem) => {
|
||||||
|
const levelsArr = Array.isArray(r.levels) && r.levels.length > 0 ? r.levels : ALL_CEFR_LEVELS;
|
||||||
|
setEditTarget(r);
|
||||||
|
setEditForm({
|
||||||
|
name: r.name,
|
||||||
|
skill: r.skill || "writing",
|
||||||
|
exam_type: r.exam_type || "academic",
|
||||||
|
criteria: parseCriteriaText(r.criteria_text, levelsArr),
|
||||||
|
levels: new Set(levelsArr),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formToPayload = (form: RubricFormState) => ({
|
||||||
|
name: form.name.trim(),
|
||||||
|
skill: form.skill,
|
||||||
|
exam_type: form.exam_type,
|
||||||
|
criteria: JSON.stringify(form.criteria.filter((c) => c.name.trim())),
|
||||||
|
levels: [...form.levels],
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = rubrics.filter((r) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return r.name?.toLowerCase().includes(q) || r.skill?.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCriterion = (
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
index: number,
|
||||||
|
patch: Partial<CriterionEntry>,
|
||||||
|
) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const criteria = [...f.criteria];
|
||||||
|
criteria[index] = { ...criteria[index], ...patch };
|
||||||
|
return { ...f, criteria };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDescriptor = (
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
cIndex: number,
|
||||||
|
level: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const criteria = [...f.criteria];
|
||||||
|
criteria[cIndex] = {
|
||||||
|
...criteria[cIndex],
|
||||||
|
descriptors: { ...criteria[cIndex].descriptors, [level]: value },
|
||||||
|
};
|
||||||
|
return { ...f, criteria };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderForm = (
|
||||||
|
form: RubricFormState,
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
|
||||||
|
) => {
|
||||||
|
const levelsArr = [...form.levels].sort(
|
||||||
|
(a, b) => ALL_CEFR_LEVELS.indexOf(a) - ALL_CEFR_LEVELS.indexOf(b)
|
||||||
|
);
|
||||||
|
const totalWeight = form.criteria.reduce((s, c) => s + (c.weight || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rubric Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
placeholder="e.g. IELTS Writing Task 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Skill</Label>
|
||||||
|
<Select value={form.skill} onValueChange={(v) => setForm((f) => ({ ...f, skill: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SKILL_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Exam Type</Label>
|
||||||
|
<Select value={form.exam_type} onValueChange={(v) => setForm((f) => ({ ...f, exam_type: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXAM_TYPE_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CEFR Levels</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_CEFR_LEVELS.map((level) => {
|
||||||
|
const checked = form.levels.has(level);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={level}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 cursor-pointer transition-all text-sm font-medium ${
|
||||||
|
checked ? "bg-primary/10 border-primary text-primary" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(val) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const next = new Set(f.levels);
|
||||||
|
if (val) next.add(level); else next.delete(level);
|
||||||
|
return { ...f, levels: next };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{level}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Criteria</Label>
|
||||||
|
{form.criteria.length > 0 && (
|
||||||
|
<span className={`ml-2 text-xs ${totalWeight === 100 ? "text-green-600" : "text-amber-600"}`}>
|
||||||
|
(Total weight: {totalWeight}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 bg-gradient-to-r from-violet-500 to-indigo-500 text-white hover:from-violet-600 hover:to-indigo-600"
|
||||||
|
disabled={suggesting}
|
||||||
|
onClick={() => suggestCriteria(form, setForm)}
|
||||||
|
>
|
||||||
|
{suggesting
|
||||||
|
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Generating...</>
|
||||||
|
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" /> Generate with AI</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggesting ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed border-violet-300 bg-violet-50/50">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
|
||||||
|
<p className="text-sm font-medium">AI is generating criteria with band descriptors...</p>
|
||||||
|
<p className="text-xs">Based on {form.skill} / {form.exam_type.replace(/_/g, " ")}</p>
|
||||||
|
</div>
|
||||||
|
) : form.criteria.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
|
||||||
|
<Sparkles className="h-5 w-5 text-violet-400" />
|
||||||
|
<p className="text-sm">No criteria yet.</p>
|
||||||
|
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or add manually.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Manually
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{form.criteria.map((c, ci) => (
|
||||||
|
<Collapsible key={ci} defaultOpen={form.criteria.length <= 3}>
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Input
|
||||||
|
value={c.name}
|
||||||
|
onChange={(e) => updateCriterion(setForm, ci, { name: e.target.value })}
|
||||||
|
placeholder="Criterion name"
|
||||||
|
className="flex-1 h-8 font-medium"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={c.weight}
|
||||||
|
onChange={(e) => updateCriterion(setForm, ci, { weight: Number(e.target.value) })}
|
||||||
|
className="w-16 h-8 text-center text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: f.criteria.filter((_, i) => i !== ci) }))}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="px-3 pb-3 pt-0 border-t">
|
||||||
|
<p className="text-xs text-muted-foreground my-2">Band level descriptors:</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{levelsArr.map((level) => (
|
||||||
|
<div key={level} className="flex gap-2 items-start">
|
||||||
|
<Badge variant="outline" className="mt-1.5 shrink-0 w-9 justify-center text-xs">{level}</Badge>
|
||||||
|
<Textarea
|
||||||
|
value={c.descriptors[level] || ""}
|
||||||
|
onChange={(e) => updateDescriptor(setForm, ci, level, e.target.value)}
|
||||||
|
placeholder={`Performance descriptor for ${level}...`}
|
||||||
|
className="min-h-[40px] h-10 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> Add Criterion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -53,25 +532,25 @@ export default function RubricsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<AiCreationAssistant type="rubric" />
|
<AiCreationAssistant type="rubric" />
|
||||||
<Dialog>
|
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
|
<DialogHeader>
|
||||||
<div className="space-y-4">
|
<DialogTitle>Create Rubric</DialogTitle>
|
||||||
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
|
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
|
||||||
<div className="space-y-3">
|
</DialogHeader>
|
||||||
<Label>Level Descriptors</Label>
|
{renderForm(createForm, setCreateForm)}
|
||||||
{["A1","A2","B1","B2","C1","C2"].map(level => (
|
<DialogFooter>
|
||||||
<div key={level} className="space-y-1">
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||||
<Label className="text-xs text-muted-foreground">{level}</Label>
|
<Button
|
||||||
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
|
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
|
||||||
</div>
|
onClick={() => createMut.mutate(formToPayload(createForm))}
|
||||||
))}
|
>
|
||||||
</div>
|
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
|
||||||
<Button className="w-full">Create Rubric</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,51 +569,248 @@ export default function RubricsPage() {
|
|||||||
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="rubrics" className="mt-4">
|
<TabsContent value="rubrics" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
{rubricsQ.isLoading && (
|
||||||
<CardContent className="p-0">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Table>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<TableHeader>
|
</div>
|
||||||
<TableRow>
|
)}
|
||||||
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
|
{rubricsQ.error && (
|
||||||
</TableRow>
|
<Card className="border-destructive">
|
||||||
</TableHeader>
|
<CardContent className="p-4 text-sm text-destructive">
|
||||||
<TableBody>
|
Failed to load rubrics. The backend endpoint may not be available yet.
|
||||||
{rubrics.map((r) => (
|
</CardContent>
|
||||||
<TableRow key={r.id}>
|
</Card>
|
||||||
<TableCell className="font-medium">{r.name}</TableCell>
|
)}
|
||||||
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
|
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
|
||||||
<TableCell>{r.criteria}</TableCell>
|
<Card className="border-dashed">
|
||||||
<TableCell>{r.created}</TableCell>
|
<CardContent className="p-8 text-center text-muted-foreground">
|
||||||
|
No rubrics found. Create one to get started.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{!rubricsQ.isLoading && filtered.length > 0 && (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Skill</TableHead>
|
||||||
|
<TableHead>Exam Type</TableHead>
|
||||||
|
<TableHead>Levels</TableHead>
|
||||||
|
<TableHead>Criteria</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[90px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filtered.map((r) => (
|
||||||
</CardContent>
|
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
|
||||||
</Card>
|
<TableCell className="font-medium">{r.name}</TableCell>
|
||||||
|
<TableCell><Badge variant="secondary" className="capitalize">{r.skill || "—"}</Badge></TableCell>
|
||||||
|
<TableCell className="capitalize text-sm">{r.exam_type?.replace(/_/g, " ") || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(r.levels || ALL_CEFR_LEVELS).map((l) => (
|
||||||
|
<Badge key={l} variant="outline" className="text-xs">{l}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{r.criteria}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{r.created}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(r.id); }}
|
||||||
|
disabled={deleteMut.isPending}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="groups" className="mt-4">
|
<TabsContent value="groups" className="mt-4">
|
||||||
<Card className="border-0 shadow-sm">
|
<div className="flex justify-end mb-3">
|
||||||
<CardContent className="p-0">
|
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
|
||||||
<Table>
|
<Plus className="h-4 w-4 mr-1" /> Create Group
|
||||||
<TableHeader>
|
</Button>
|
||||||
<TableRow>
|
</div>
|
||||||
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
|
{rubricGroupsQ.isLoading && (
|
||||||
</TableRow>
|
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
|
||||||
{rubricGroups.map((g) => (
|
<Card className="border-dashed"><CardContent className="p-8 text-center text-muted-foreground text-sm">No rubric groups yet. Create one to bundle rubrics together.</CardContent></Card>
|
||||||
<TableRow key={g.id}>
|
)}
|
||||||
<TableCell className="font-medium">{g.name}</TableCell>
|
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
|
||||||
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
|
<Card className="border-0 shadow-sm">
|
||||||
<TableCell>{g.created}</TableCell>
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Group Name</TableHead>
|
||||||
|
<TableHead>Rubrics</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{rubricGroups.map((g) => (
|
||||||
</CardContent>
|
<TableRow key={g.id}>
|
||||||
</Card>
|
<TableCell className="font-medium">{g.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{(g.rubric_names ?? []).map((rb) => (
|
||||||
|
<Badge key={rb} variant="secondary" className="text-xs">{rb}</Badge>
|
||||||
|
))}
|
||||||
|
{(!g.rubric_names || g.rubric_names.length === 0) && (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No rubrics</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{g.created}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
|
||||||
|
setGroupEditTarget({ id: g.id, name: g.name, rubric_ids: g.rubric_ids });
|
||||||
|
setGroupEditForm({ name: g.name, rubric_ids: new Set(g.rubric_ids) });
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => { if (confirm(`Delete group "${g.name}"?`)) deleteGroupMut.mutate(g.id); }}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Group Dialog */}
|
||||||
|
<Dialog open={groupCreateOpen} onOpenChange={setGroupCreateOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Rubric Group</DialogTitle>
|
||||||
|
<DialogDescription>Bundle multiple rubrics into a group for easy selection.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Group Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={groupCreateForm.name} onChange={(e) => setGroupCreateForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Academic IELTS Full" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Rubrics</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={groupCreateForm.rubric_ids.has(r.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupCreateForm((p) => {
|
||||||
|
const next = new Set(p.rubric_ids);
|
||||||
|
checked ? next.add(r.id) : next.delete(r.id);
|
||||||
|
return { ...p, rubric_ids: next };
|
||||||
|
});
|
||||||
|
}} className="h-3.5 w-3.5" />
|
||||||
|
{r.name}
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{rubrics.length === 0 && <p className="text-xs text-muted-foreground italic py-2 text-center">No rubrics available</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setGroupCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button disabled={!groupCreateForm.name.trim() || createGroupMut.isPending}
|
||||||
|
onClick={() => createGroupMut.mutate({ name: groupCreateForm.name.trim(), rubric_ids: [...groupCreateForm.rubric_ids] })}>
|
||||||
|
{createGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create Group"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Group Dialog */}
|
||||||
|
<Dialog open={!!groupEditTarget} onOpenChange={(open) => { if (!open) setGroupEditTarget(null); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Rubric Group</DialogTitle>
|
||||||
|
<DialogDescription>Update the group name and selected rubrics.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Group Name <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={groupEditForm.name} onChange={(e) => setGroupEditForm((p) => ({ ...p, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Select Rubrics</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
|
||||||
|
{rubrics.map((r) => (
|
||||||
|
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={groupEditForm.rubric_ids.has(r.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setGroupEditForm((p) => {
|
||||||
|
const next = new Set(p.rubric_ids);
|
||||||
|
checked ? next.add(r.id) : next.delete(r.id);
|
||||||
|
return { ...p, rubric_ids: next };
|
||||||
|
});
|
||||||
|
}} className="h-3.5 w-3.5" />
|
||||||
|
{r.name}
|
||||||
|
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setGroupEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button disabled={!groupEditForm.name.trim() || updateGroupMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!groupEditTarget) return;
|
||||||
|
updateGroupMut.mutate({ id: groupEditTarget.id, data: { name: groupEditForm.name.trim(), rubric_ids: [...groupEditForm.rubric_ids] } });
|
||||||
|
}}>
|
||||||
|
{updateGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<Dialog open={!!editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null); }}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Rubric</DialogTitle>
|
||||||
|
<DialogDescription>Update the rubric details below. Use AI to regenerate criteria.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{renderForm(editForm, setEditForm)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditTarget(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!editForm.name.trim() || editForm.criteria.filter((c) => c.name.trim()).length === 0 || updateMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editTarget) return;
|
||||||
|
updateMut.mutate({ id: editTarget.id, data: formToPayload(editForm) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{updateMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,20 +39,35 @@ interface FeedbackEntry {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AnswerEntry {
|
||||||
|
question_id: number;
|
||||||
|
answer: string;
|
||||||
|
score: number;
|
||||||
|
is_correct: boolean;
|
||||||
|
feedback: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ExamResultsData {
|
interface ExamResultsData {
|
||||||
attempt_id: number;
|
attempt_id: number;
|
||||||
exam_id: number | null;
|
exam_id: number | null;
|
||||||
|
exam_title?: string;
|
||||||
status: string;
|
status: string;
|
||||||
completed_at: string;
|
completed_at?: string;
|
||||||
released_at: string;
|
released_at?: string;
|
||||||
listening_band: number;
|
started_at?: string | null;
|
||||||
reading_band: number;
|
finished_at?: string | null;
|
||||||
writing_band: number;
|
total_score?: number;
|
||||||
speaking_band: number;
|
max_score?: number;
|
||||||
overall_band: number;
|
percentage?: number;
|
||||||
cefr_level: string;
|
listening_band?: number;
|
||||||
scores: ScoreEntry[];
|
reading_band?: number;
|
||||||
feedback: FeedbackEntry[];
|
writing_band?: number;
|
||||||
|
speaking_band?: number;
|
||||||
|
overall_band?: number;
|
||||||
|
cefr_level?: string;
|
||||||
|
scores?: ScoreEntry[];
|
||||||
|
feedback?: FeedbackEntry[];
|
||||||
|
answers?: AnswerEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamResults() {
|
export default function ExamResults() {
|
||||||
@@ -99,11 +114,13 @@ export default function ExamResults() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overall = results.overall_band;
|
const hasDetailedScores = Array.isArray(results.scores) && results.scores.length > 0;
|
||||||
|
const overall = results.overall_band ?? 0;
|
||||||
|
const pct = results.percentage ?? (results.max_score ? Math.round((results.total_score ?? 0) / results.max_score * 100) : 0);
|
||||||
const cefr = results.cefr_level?.toUpperCase() || "N/A";
|
const cefr = results.cefr_level?.toUpperCase() || "N/A";
|
||||||
const passed = overall >= 7;
|
const passed = hasDetailedScores ? overall >= 7 : pct >= 70;
|
||||||
|
|
||||||
const skillScores = results.scores.filter((s) => s.skill !== "overall");
|
const skillScores = hasDetailedScores ? results.scores!.filter((s) => s.skill !== "overall") : [];
|
||||||
const SKILLS = skillScores.map((s) => ({
|
const SKILLS = skillScores.map((s) => ({
|
||||||
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
|
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
|
||||||
band: s.band_score,
|
band: s.band_score,
|
||||||
@@ -114,25 +131,39 @@ export default function ExamResults() {
|
|||||||
|
|
||||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||||
|
|
||||||
|
const feedbackList = results.feedback ?? [];
|
||||||
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
|
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
|
||||||
for (const fb of results.feedback) {
|
for (const fb of feedbackList) {
|
||||||
const key = "General";
|
const key = "General";
|
||||||
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
|
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
|
||||||
feedbackBySkill[key].push(fb);
|
feedbackBySkill[key].push(fb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const answerEntries = results.answers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
{results.exam_title && (
|
||||||
|
<h1 className="text-xl font-semibold mb-1">{results.exam_title}</h1>
|
||||||
|
)}
|
||||||
<p className="text-sm text-muted-foreground">Your overall result</p>
|
<p className="text-sm text-muted-foreground">Your overall result</p>
|
||||||
<div className="mt-2 flex items-center justify-center gap-3">
|
{hasDetailedScores ? (
|
||||||
<Award className="h-12 w-12 text-primary" />
|
<div className="mt-2 flex items-center justify-center gap-3">
|
||||||
<span className="text-5xl font-bold tabular-nums">{overall}</span>
|
<Award className="h-12 w-12 text-primary" />
|
||||||
<Badge variant="secondary" className="text-lg">
|
<span className="text-5xl font-bold tabular-nums">{overall}</span>
|
||||||
Band
|
<Badge variant="secondary" className="text-lg">Band</Badge>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
|
<div className="mt-2 flex items-center justify-center gap-3">
|
||||||
|
<Award className="h-12 w-12 text-primary" />
|
||||||
|
<span className="text-5xl font-bold tabular-nums">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{results.total_score ?? 0} / {results.max_score ?? 0} marks
|
||||||
|
</p>
|
||||||
|
{hasDetailedScores && <p className="mt-1 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>}
|
||||||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,11 +216,48 @@ export default function ExamResults() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{results.feedback.length > 0 && (
|
{answerEntries.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Your Answers</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{answerEntries.filter((a) => a.is_correct).length} correct out of {answerEntries.length}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>Your Answer</TableHead>
|
||||||
|
<TableHead>Score</TableHead>
|
||||||
|
<TableHead>Result</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{answerEntries.map((a, i) => (
|
||||||
|
<TableRow key={a.question_id}>
|
||||||
|
<TableCell>{i + 1}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">{a.answer || "—"}</TableCell>
|
||||||
|
<TableCell>{a.score}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={a.is_correct ? "default" : "destructive"}>
|
||||||
|
{a.is_correct ? "Correct" : "Incorrect"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedbackList.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||||||
<Accordion type="multiple" className="w-full">
|
<Accordion type="multiple" className="w-full">
|
||||||
{results.feedback.map((fb, i) => (
|
{feedbackList.map((fb, i) => (
|
||||||
<AccordionItem key={i} value={`fb-${i}`}>
|
<AccordionItem key={i} value={`fb-${i}`}>
|
||||||
<AccordionTrigger>
|
<AccordionTrigger>
|
||||||
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
|
||||||
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
|
import type { ExamAnswer, ExamOptionItem, ExamQuestion, ExamSessionSection } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
@@ -27,6 +27,11 @@ function normalizeType(t: string | null | undefined) {
|
|||||||
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOption(o: ExamOptionItem): { label: string; text: string } {
|
||||||
|
if (typeof o === "string") return { label: o, text: o };
|
||||||
|
return { label: o.label ?? o.text ?? "", text: o.text ?? o.label ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
function countWords(s: string) {
|
function countWords(s: string) {
|
||||||
return s.trim() ? s.trim().split(/\s+/).length : 0;
|
return s.trim() ? s.trim().split(/\s+/).length : 0;
|
||||||
}
|
}
|
||||||
@@ -222,18 +227,19 @@ export default function ExamSession() {
|
|||||||
const a = answers.get(q.id);
|
const a = answers.get(q.id);
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
const nt = normalizeType(q.type);
|
const nt = normalizeType(q.type);
|
||||||
|
const opts = (q.options ?? []).map(normalizeOption);
|
||||||
|
|
||||||
if (nt.includes("listen") || q.audio_url) {
|
if (nt.includes("listen") || q.audio_url) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
||||||
{q.options?.length ? (
|
{opts.length ? (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={typeof a.answer === "string" ? a.answer : ""}
|
value={typeof a.answer === "string" ? a.answer : ""}
|
||||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
{q.options.map((o) => (
|
{opts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||||
@@ -249,7 +255,7 @@ export default function ExamSession() {
|
|||||||
const selected = Array.isArray(a.answer) ? a.answer : [];
|
const selected = Array.isArray(a.answer) ? a.answer : [];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{q.options?.map((o) => (
|
{opts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`${q.id}-${o.label}`}
|
id={`${q.id}-${o.label}`}
|
||||||
@@ -267,7 +273,7 @@ export default function ExamSession() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nt.includes("gap")) {
|
if (nt.includes("gap") || nt.includes("fill") || nt.includes("short_answer") || nt.includes("summary")) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
value={typeof a.answer === "string" ? a.answer : ""}
|
value={typeof a.answer === "string" ? a.answer : ""}
|
||||||
@@ -279,12 +285,12 @@ export default function ExamSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
|
||||||
const opts = q.options?.length
|
const tfOpts = opts.length
|
||||||
? q.options
|
? opts
|
||||||
: [
|
: [
|
||||||
{ label: "T", text: "True" },
|
{ label: "TRUE", text: "TRUE" },
|
||||||
{ label: "F", text: "False" },
|
{ label: "FALSE", text: "FALSE" },
|
||||||
{ label: "NG", text: "Not Given" },
|
{ label: "NOT GIVEN", text: "NOT GIVEN" },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@@ -292,7 +298,7 @@ export default function ExamSession() {
|
|||||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
{opts.map((o) => (
|
{tfOpts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||||
@@ -338,7 +344,7 @@ export default function ExamSession() {
|
|||||||
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
>
|
>
|
||||||
{q.options?.map((o) => (
|
{opts.map((o) => (
|
||||||
<div key={o.label} className="flex items-center space-x-2">
|
<div key={o.label} className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
|
||||||
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
|
||||||
@@ -389,13 +395,39 @@ export default function ExamSession() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
|
||||||
<ScrollArea className="flex-1 p-6">
|
{section?.passage_text ? (
|
||||||
|
<ScrollArea className="w-full md:w-1/2 border-r p-6">
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<h3 className="text-base font-semibold mb-2">{section.title}</h3>
|
||||||
|
{section.difficulty && (
|
||||||
|
<span className="inline-block text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded mb-3">
|
||||||
|
Level: {section.difficulty}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-line">
|
||||||
|
{section.passage_text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
<ScrollArea className={cn("flex-1 p-6", section?.passage_text && "md:w-1/2")}>
|
||||||
{question ? (
|
{question ? (
|
||||||
<Card className="border-none shadow-none">
|
<Card className="border-none shadow-none">
|
||||||
<CardContent className="space-y-6 p-0">
|
<CardContent className="space-y-6 p-0">
|
||||||
{question.passage_text ? (
|
{section?.instructions_text ? (
|
||||||
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
|
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-sm leading-relaxed border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">Instructions</p>
|
||||||
|
{section.instructions_text}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="inline-flex items-center justify-center h-7 w-7 rounded-full bg-primary text-primary-foreground text-xs font-bold">
|
||||||
|
{questionIdx + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
of {section?.questions.length ?? 0} · {question.marks} mark{question.marks !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
<p className="font-medium">{question.stem}</p>
|
<p className="font-medium">{question.stem}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type { Assignment, AssignmentState, AssignmentCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
import type {
|
||||||
|
Assignment,
|
||||||
|
AssignmentState,
|
||||||
|
AssignmentCreateRequest,
|
||||||
|
ExamSchedule,
|
||||||
|
ExamScheduleCreateRequest,
|
||||||
|
ScheduleState,
|
||||||
|
StudentExamAssignment,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
ApiSuccessResponse,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
export interface AssignmentListParams extends PaginationParams {
|
export interface AssignmentListParams extends PaginationParams {
|
||||||
state?: AssignmentState;
|
state?: AssignmentState;
|
||||||
entity_id?: number;
|
entity_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduleListParams extends PaginationParams {
|
||||||
|
state?: ScheduleState;
|
||||||
|
}
|
||||||
|
|
||||||
export const assignmentsService = {
|
export const assignmentsService = {
|
||||||
async list(params?: AssignmentListParams): Promise<PaginatedResponse<Assignment>> {
|
async list(params?: AssignmentListParams): Promise<PaginatedResponse<Assignment>> {
|
||||||
return api.get<PaginatedResponse<Assignment>>("/assignments", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<Assignment>>("/assignments", params as Record<string, string | number | boolean | undefined>);
|
||||||
@@ -34,4 +49,28 @@ export const assignmentsService = {
|
|||||||
async start(id: number): Promise<Assignment> {
|
async start(id: number): Promise<Assignment> {
|
||||||
return api.post<Assignment>(`/assignments/${id}/start`);
|
return api.post<Assignment>(`/assignments/${id}/start`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listSchedules(params?: ScheduleListParams): Promise<PaginatedResponse<ExamSchedule>> {
|
||||||
|
return api.get<PaginatedResponse<ExamSchedule>>("/exam-schedules", params as Record<string, string | number | boolean | undefined>);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSchedule(data: ExamScheduleCreateRequest): Promise<ExamSchedule> {
|
||||||
|
return api.post<ExamSchedule>("/exam-schedules", data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSchedule(id: number, data: Partial<ExamScheduleCreateRequest & { state: ScheduleState }>): Promise<ExamSchedule> {
|
||||||
|
return api.put<ExamSchedule>(`/exam-schedules/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSchedule(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/exam-schedules/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async archiveSchedule(id: number): Promise<ExamSchedule> {
|
||||||
|
return api.post<ExamSchedule>(`/exam-schedules/${id}/archive`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStudentExams(): Promise<{ items: StudentExamAssignment[] }> {
|
||||||
|
return api.get<{ items: StudentExamAssignment[] }>("/student/my-exams");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,30 @@ export const examsService = {
|
|||||||
return api.post<Rubric>("/rubrics", data);
|
return api.post<Rubric>("/rubrics", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateRubric(id: number, data: Partial<Rubric>): Promise<Rubric> {
|
||||||
|
return api.put<Rubric>(`/rubrics/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRubric(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/rubrics/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async listRubricGroups(params?: PaginationParams): Promise<PaginatedResponse<RubricGroup>> {
|
async listRubricGroups(params?: PaginationParams): Promise<PaginatedResponse<RubricGroup>> {
|
||||||
return api.get<PaginatedResponse<RubricGroup>>("/rubric-groups", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<RubricGroup>>("/rubric-groups", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async createRubricGroup(data: { name: string; rubric_ids: number[] }): Promise<RubricGroup> {
|
||||||
|
return api.post<RubricGroup>("/rubric-groups", data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateRubricGroup(id: number, data: { name?: string; rubric_ids?: number[] }): Promise<RubricGroup> {
|
||||||
|
return api.put<RubricGroup>(`/rubric-groups/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteRubricGroup(id: number): Promise<ApiSuccessResponse> {
|
||||||
|
return api.delete<ApiSuccessResponse>(`/rubric-groups/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
async listStructures(params?: PaginationParams & { entity_id?: number }): Promise<PaginatedResponse<ExamStructure>> {
|
async listStructures(params?: PaginationParams & { entity_id?: number }): Promise<PaginatedResponse<ExamStructure>> {
|
||||||
return api.get<PaginatedResponse<ExamStructure>>("/exam-structures", params as Record<string, string | number | boolean | undefined>);
|
return api.get<PaginatedResponse<ExamStructure>>("/exam-structures", params as Record<string, string | number | boolean | undefined>);
|
||||||
},
|
},
|
||||||
@@ -52,10 +72,23 @@ export const examsService = {
|
|||||||
return api.post<ExamStructure>("/exam-structures", data);
|
return api.post<ExamStructure>("/exam-structures", data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateStructure(id: number, data: Partial<ExamStructure>): Promise<ExamStructure> {
|
||||||
|
return api.put<ExamStructure>(`/exam-structures/${id}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
async deleteStructure(id: number): Promise<ApiSuccessResponse> {
|
async deleteStructure(id: number): Promise<ApiSuccessResponse> {
|
||||||
return api.delete<ApiSuccessResponse>(`/exam-structures/${id}`);
|
return api.delete<ApiSuccessResponse>(`/exam-structures/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async suggestRubricCriteria(data: {
|
||||||
|
name?: string;
|
||||||
|
skill: string;
|
||||||
|
exam_type: string;
|
||||||
|
levels: string[];
|
||||||
|
}): Promise<{ criteria: string[]; suggested_levels?: string[] }> {
|
||||||
|
return api.post("/ai/suggest-rubric-criteria", data);
|
||||||
|
},
|
||||||
|
|
||||||
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string }[]> {
|
async getAvatars(): Promise<{ id: number; name: string; thumbnail: string }[]> {
|
||||||
return api.get("/exam/avatars");
|
return api.get("/exam/avatars");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ export const generationService = {
|
|||||||
submitExam(data: {
|
submitExam(data: {
|
||||||
title: string;
|
title: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
exam_mode?: string;
|
||||||
|
structure_id?: number;
|
||||||
modules: Record<string, unknown>;
|
modules: Record<string, unknown>;
|
||||||
skip_approval?: boolean;
|
skip_approval?: boolean;
|
||||||
}): Promise<{ exam_id: number; status: string; template_id?: number }> {
|
}): Promise<{ exam_id: number; status: string; template_id?: number; total_questions?: number }> {
|
||||||
return api.post("/exam/generation/submit", data);
|
return api.post("/exam/generation/submit", data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,3 +35,60 @@ export interface AssignmentCreateRequest {
|
|||||||
exam_ids: number[];
|
exam_ids: number[];
|
||||||
assignee_ids?: number[];
|
assignee_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScheduleState = "planned" | "active" | "past" | "start_expired" | "archived";
|
||||||
|
|
||||||
|
export interface ExamSchedule {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
exam_id: number | null;
|
||||||
|
exam_title: string;
|
||||||
|
entity_id: number | null;
|
||||||
|
entity_name: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
state: ScheduleState;
|
||||||
|
assign_mode: "entity" | "batch" | "individual";
|
||||||
|
full_length: boolean;
|
||||||
|
generate_different: boolean;
|
||||||
|
auto_release_results: boolean;
|
||||||
|
auto_start: boolean;
|
||||||
|
official_exam: boolean;
|
||||||
|
hide_assignee_details: boolean;
|
||||||
|
batch_ids: number[];
|
||||||
|
batch_names: string[];
|
||||||
|
student_ids: number[];
|
||||||
|
assignee_count: number;
|
||||||
|
completed_count: number;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamScheduleCreateRequest {
|
||||||
|
name: string;
|
||||||
|
exam_id: number;
|
||||||
|
entity_id?: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
assign_mode: "entity" | "batch" | "individual";
|
||||||
|
batch_ids?: number[];
|
||||||
|
student_ids?: number[];
|
||||||
|
full_length?: boolean;
|
||||||
|
generate_different?: boolean;
|
||||||
|
auto_release_results?: boolean;
|
||||||
|
auto_start?: boolean;
|
||||||
|
official_exam?: boolean;
|
||||||
|
hide_assignee_details?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StudentExamAssignment {
|
||||||
|
id: number;
|
||||||
|
exam_id: number;
|
||||||
|
exam_title: string;
|
||||||
|
schedule_name: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
status: string;
|
||||||
|
schedule_state: ScheduleState;
|
||||||
|
auto_start: boolean;
|
||||||
|
can_start: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,20 +10,33 @@ export interface ExamSessionSection {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
skill: string;
|
skill: string;
|
||||||
|
difficulty?: string;
|
||||||
time_limit_sec: number;
|
time_limit_sec: number;
|
||||||
|
time_limit_min?: number;
|
||||||
|
total_marks?: number;
|
||||||
|
scoring_method?: string;
|
||||||
|
sequence?: number;
|
||||||
|
passage_text?: string;
|
||||||
|
instructions_text?: string;
|
||||||
questions: ExamQuestion[];
|
questions: ExamQuestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExamOptionItem = { label: string; text: string } | string;
|
||||||
|
|
||||||
export interface ExamQuestion {
|
export interface ExamQuestion {
|
||||||
id: number;
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
|
question_type?: string;
|
||||||
stem: string;
|
stem: string;
|
||||||
options?: { label: string; text: string }[];
|
options?: ExamOptionItem[];
|
||||||
passage_text?: string;
|
passage_text?: string;
|
||||||
audio_url?: string;
|
audio_url?: string;
|
||||||
|
audio_base64?: string;
|
||||||
visual_url?: string;
|
visual_url?: string;
|
||||||
min_words?: number;
|
min_words?: number;
|
||||||
marks: number;
|
marks: number;
|
||||||
|
difficulty?: string;
|
||||||
|
source_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamAnswer {
|
export interface ExamAnswer {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface RubricGroup {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
rubric_ids: number[];
|
rubric_ids: number[];
|
||||||
|
rubric_names?: string[];
|
||||||
|
created?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamStructure {
|
export interface ExamStructure {
|
||||||
@@ -44,6 +46,77 @@ export interface ExamStructure {
|
|||||||
name: string;
|
name: string;
|
||||||
entity_id: number;
|
entity_id: number;
|
||||||
entity_name: string;
|
entity_name: string;
|
||||||
|
industry: string;
|
||||||
modules: ExamModule[];
|
modules: ExamModule[];
|
||||||
|
config: ExamStructureConfig;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListeningPartConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
questions: number;
|
||||||
|
question_types: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadingPassageConfig {
|
||||||
|
style: string;
|
||||||
|
questions: number;
|
||||||
|
question_types: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakingPartConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
duration_min: number;
|
||||||
|
duration_max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritingTaskConfig {
|
||||||
|
type: string;
|
||||||
|
min_words: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LevelExerciseEntry {
|
||||||
|
type: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndustryExerciseEntry {
|
||||||
|
type: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamStructureConfig {
|
||||||
|
exam_type: "academic" | "general";
|
||||||
|
listening?: {
|
||||||
|
parts: ListeningPartConfig[];
|
||||||
|
total_questions: number;
|
||||||
|
};
|
||||||
|
reading?: {
|
||||||
|
passages: ReadingPassageConfig[];
|
||||||
|
total_questions: number;
|
||||||
|
};
|
||||||
|
writing?: {
|
||||||
|
tasks: WritingTaskConfig[];
|
||||||
|
/** @deprecated kept for backward compat reads */
|
||||||
|
task1?: { type: string; min_words: number };
|
||||||
|
/** @deprecated kept for backward compat reads */
|
||||||
|
task2?: { type: string; min_words: number };
|
||||||
|
rubric_id: number | null;
|
||||||
|
};
|
||||||
|
speaking?: {
|
||||||
|
parts: SpeakingPartConfig[];
|
||||||
|
rubric_id: number | null;
|
||||||
|
};
|
||||||
|
level?: {
|
||||||
|
exercise_types: Record<string, number>;
|
||||||
|
entries?: LevelExerciseEntry[];
|
||||||
|
};
|
||||||
|
industry?: {
|
||||||
|
exercise_types: Record<string, number>;
|
||||||
|
entries?: IndustryExerciseEntry[];
|
||||||
|
difficulty: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ db_host = localhost
|
|||||||
db_port = 5432
|
db_port = 5432
|
||||||
http_interface = 127.0.0.1
|
http_interface = 127.0.0.1
|
||||||
http_port = 8069
|
http_port = 8069
|
||||||
addons_path = /Users/yamenahmad/projects2026/odoo/odoo19/addons_extra,/Users/yamenahmad/projects2026/odoo/odoo19/addons_enterprise,/Users/yamenahmad/projects2026/odoo/odoo19/odoo/addons
|
addons_path = /Users/yamenahmad/projects2026/odoo/odoo19/backend/custom_addons,/Users/yamenahmad/projects2026/odoo/odoo19/backend/openeducat_erp-19.0/openeducat_erp-19.0,/Users/yamenahmad/projects2026/odoo/odoo19/addons_extra,/Users/yamenahmad/projects2026/odoo/odoo19/addons_enterprise,/Users/yamenahmad/projects2026/odoo/odoo19/odoo/addons
|
||||||
admin_passwd = $pbkdf2-sha512$600000$W4ux9h5DyDmnFIIQ4hxDaA$bF8qJJWZLTs2IC8T74YWv1my44u4vsqvLXUfexx2I1kGvPXMwHJiZOMhaYxmC3GAuIxQI1/8HPvdQhqB8OoVMQ
|
admin_passwd = $pbkdf2-sha512$600000$W4ux9h5DyDmnFIIQ4hxDaA$bF8qJJWZLTs2IC8T74YWv1my44u4vsqvLXUfexx2I1kGvPXMwHJiZOMhaYxmC3GAuIxQI1/8HPvdQhqB8OoVMQ
|
||||||
workers = 4
|
workers = 4
|
||||||
max_cron_threads = 1
|
max_cron_threads = 1
|
||||||
|
|||||||
Reference in New Issue
Block a user