diff --git a/custom_addons/encoach_ai/controllers/ai_controller.py b/custom_addons/encoach_ai/controllers/ai_controller.py index d48684f..c7ae302 100644 --- a/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/custom_addons/encoach_ai/controllers/ai_controller.py @@ -27,7 +27,7 @@ class AIController(http.Controller): """Handles /api/ai/* endpoints consumed by frontend AI components.""" # ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ── - @http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/search", type="http", auth="public", methods=["POST"], csrf=False) def ai_search(self, **kw): body = _get_json() query = body.get("query", "") @@ -43,7 +43,7 @@ class AIController(http.Controller): return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []}) # ── GET /api/ai/vector-search — pure semantic search without GPT ── - @http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/ai/vector-search", type="http", auth="public", methods=["GET"], csrf=False) def ai_vector_search(self, **kw): query = request.params.get("q", "") content_type = request.params.get("content_type") @@ -60,7 +60,7 @@ class AIController(http.Controller): return _json_response({"results": [], "query": query, "error": str(e)}) # ── POST /api/ai/insights — AiInsightsPanel.tsx ── - @http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/insights", type="http", auth="public", methods=["POST"], csrf=False) def ai_insights(self, **kw): body = _get_json() try: @@ -76,7 +76,7 @@ class AIController(http.Controller): return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]}) # ── GET /api/ai/alerts — AiAlertBanner.tsx ── - @http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/ai/alerts", type="http", auth="public", methods=["GET"], csrf=False) def ai_alerts(self, **kw): try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService @@ -92,7 +92,7 @@ class AIController(http.Controller): return _json_response({"alerts": []}) # ── POST /api/ai/report-narrative — AiReportNarrative.tsx ── - @http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/report-narrative", type="http", auth="public", methods=["POST"], csrf=False) def ai_report_narrative(self, **kw): body = _get_json() try: @@ -107,7 +107,7 @@ class AIController(http.Controller): return _json_response({"narrative": f"Report generation unavailable: {e}"}) # ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ── - @http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/batch-optimize", type="http", auth="public", methods=["POST"], csrf=False) def ai_batch_optimize(self, **kw): body = _get_json() try: @@ -122,7 +122,7 @@ class AIController(http.Controller): return _json_response({"optimized": [], "summary": str(e), "impact": "none"}) # ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ── - @http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/grade-suggest", type="http", auth="public", methods=["POST"], csrf=False) def ai_grade_suggest(self, **kw): body = _get_json() try: @@ -146,7 +146,7 @@ class AIController(http.Controller): return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []}) # ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ── - @http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/generate-resource", type="http", auth="public", methods=["POST"], csrf=False) def ai_generate_resource(self, **kw): body = _get_json() try: @@ -162,7 +162,7 @@ class AIController(http.Controller): return _json_response({"resource": None, "status": "error", "error": str(e)}) # ── POST /api/ai/detect — GPTZero AI detection ── - @http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/detect", type="http", auth="public", methods=["POST"], csrf=False) def ai_detect(self, **kw): body = _get_json() try: @@ -174,7 +174,7 @@ class AIController(http.Controller): return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)}) # ── POST /api/plagiarism/check — plagiarism.service.ts ── - @http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/plagiarism/check", type="http", auth="public", methods=["POST"], csrf=False) def plagiarism_check(self, **kw): body = _get_json() try: @@ -187,7 +187,7 @@ class AIController(http.Controller): return _json_response({"report_id": None, "error": str(e)}) # ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ── - @http.route("/api/domains//ai-suggest", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/domains//ai-suggest", type="http", auth="public", methods=["POST"], csrf=False) def ai_suggest_topics(self, domain_id, **kw): body = _get_json() try: @@ -206,7 +206,7 @@ class AIController(http.Controller): return _json_response({"topics": [], "error": str(e)}) # ── POST /api/learning-plan/generate — LearningPlan.tsx ── - @http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/learning-plan/generate", type="http", auth="public", methods=["POST"], csrf=False) def learning_plan_generate(self, **kw): body = _get_json() try: @@ -227,7 +227,7 @@ class AIController(http.Controller): return _json_response({"plan": None, "error": str(e)}) # ── Workbench endpoints — AiWorkbench.tsx ── - @http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-outline", type="http", auth="public", methods=["POST"], csrf=False) def workbench_outline(self, **kw): body = _get_json() try: @@ -244,7 +244,7 @@ class AIController(http.Controller): except Exception as e: return _json_response({"chapters": [], "error": str(e)}) - @http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-chapter", type="http", auth="public", methods=["POST"], csrf=False) def workbench_chapter(self, **kw): body = _get_json() try: @@ -262,7 +262,7 @@ class AIController(http.Controller): except Exception as e: return _json_response({"content": "", "error": str(e)}) - @http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/generate-rubric", type="http", auth="public", methods=["POST"], csrf=False) def workbench_rubric(self, **kw): body = _get_json() try: @@ -280,11 +280,11 @@ class AIController(http.Controller): except Exception as e: return _json_response({"rubric": None, "error": str(e)}) - @http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/regenerate", type="http", auth="public", methods=["POST"], csrf=False) def workbench_regenerate(self, **kw): return self.workbench_chapter(**kw) - @http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/publish", type="http", auth="public", methods=["POST"], csrf=False) def workbench_publish(self, **kw): body = _get_json() try: @@ -315,7 +315,7 @@ class AIController(http.Controller): 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) + @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="public", methods=["POST"], csrf=False) def ai_suggest_rubric_criteria(self, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -450,7 +450,7 @@ class AIController(http.Controller): ] # ── Exam generation — GenerationPage.tsx ── - @http.route("/api/exam//generate", type="http", auth="none", methods=["POST"], csrf=False) + @http.route("/api/exam//generate", type="http", auth="public", methods=["POST"], csrf=False) def exam_generate(self, module, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -506,60 +506,348 @@ class AIController(http.Controller): _logger.exception("exam_generate %s failed: %s", module, e) return _json_response({"questions": [], "error": str(e)}, 500) + def _get_material_context(self, ai, query, course_id=None, subject_id=None, entity_id=None): + """Fetch relevant course material / resource context from the vector store for + RAG-enhanced generation. + + Results are re-ranked to prefer (in order): + same course_id → same subject_id → same entity_id → everything else. + """ + try: + context_results = ai._get_vector_context( + query, + content_types=['material', 'resource', 'course', 'topic', 'learning_objective'], + limit=8, + ) + if not context_results: + return "" + + def _score(r): + meta = r.get('metadata', {}) or {} + s = 0 + if course_id and meta.get('course_id') == course_id: + s += 100 + if subject_id and meta.get('subject_id') == subject_id: + s += 50 + if entity_id and meta.get('entity_id') == entity_id: + s += 25 + s += float(r.get('similarity', 0) or 0) * 10 + return -s + context_results.sort(key=_score) + return ai._format_context(context_results[:6]) + except Exception: + return "" + + # ── Persona / CEFR helpers for exam generation ──────────────────── + # Concise CEFR descriptors used to give GPT a mental model of each band. + # Keep these short — they are prepended to every prompt. + _CEFR_DESCRIPTORS = { + "A1": "A1 (Breakthrough) — very basic personal information; concrete vocabulary; short fixed phrases; simple present tense; text ~100-200 words with predictable structure.", + "A2": "A2 (Elementary) — familiar everyday topics; simple past / future; concrete connectors (and, but, because); text ~200-300 words.", + "B1": "B1 (Threshold) — familiar matters at work/school/leisure; clear standard language; can follow main points; text ~300-400 words; some abstract ideas.", + "B2": "B2 (Vantage) — complex texts on concrete and abstract topics; including technical discussions in speciality; text ~400-600 words; clear detailed argument.", + "C1": "C1 (Effective Operational Proficiency) — wide range of demanding long texts; implicit meaning; flexible and effective language use; nuanced argument; text ~600-900 words; idiomatic expressions; complex sub-clauses.", + "C2": "C2 (Mastery) — understand virtually everything with ease; reconstruct arguments; precise shades of meaning; text ~800-1200 words; sophisticated stylistic choices.", + } + + # Examiner / item-writer personas keyed by module + exam_mode. + _EXAM_MODE_LABEL = { + "official": "official high-stakes IELTS exam (summative, ranked Band 0-9)", + "practice": "practice / formative exam (low-stakes, used for learner feedback)", + } + + def _persona_for(self, module, exam_mode="official", exam_type="academic"): + mode = self._EXAM_MODE_LABEL.get(exam_mode, "standardised English exam") + et = (exam_type or "academic").lower() + et_pretty = { + "academic": "IELTS Academic", + "general": "IELTS General Training", + "general_training": "IELTS General Training", + "business": "Business English", + "ket": "Cambridge KET", "pet": "Cambridge PET", + "fce": "Cambridge FCE", "cae": "Cambridge CAE", "cpe": "Cambridge CPE", + }.get(et, et.title()) + base = f"You are a senior {et_pretty} item writer preparing content for a {mode}." + extras = { + "reading": " You specialise in CEFR-aligned reading passages whose lexical range, grammatical complexity, cohesion devices, and topic sophistication match the target band exactly.", + "listening": " You specialise in CEFR-aligned listening scripts with realistic features (hesitations, discourse markers, accents) while staying lexically and grammatically appropriate to the target band.", + "writing": " You specialise in CEFR-aligned writing prompts that elicit the task response, lexical range, grammatical range and coherence expected at the target band.", + "speaking": " You specialise in CEFR-aligned speaking cue cards and examiner scripts whose follow-up questions elicit the fluency, lexical resource, grammatical range and pronunciation expected at the target band.", + } + return base + extras.get(module, "") + + def _build_persona_context(self, body): + """Format the user-captured parameters into a readable 'exam brief' block + that the LLM sees as a dedicated system message.""" + rows = [] + + def _add(label, value): + if value in (None, "", [], {}): + return + if isinstance(value, list): + value = ", ".join(str(v) for v in value if v not in (None, "")) + if not value: + return + rows.append(f"- {label}: {value}") + + _add("Exam title", body.get("exam_title") or body.get("title")) + _add("Exam label", body.get("exam_label") or body.get("label")) + _add("Exam mode", body.get("exam_mode")) + _add("Exam structure", body.get("structure_name") or body.get("structure")) + _add("Module", body.get("module")) + _add("CEFR target level", body.get("difficulty")) + _add("Passage category", body.get("category")) + _add("Passage type", body.get("passage_type") or body.get("type")) + _add("Task type", body.get("task_type")) + _add("Speaking part", body.get("part")) + _add("Listening section type", body.get("section_type")) + _add("Target word count", body.get("word_count")) + _add("Subject", body.get("subject_name") or body.get("subject")) + _add("Topic", body.get("topic") or body.get("topics")) + _add("Entity / Organisation", body.get("entity_name")) + _add("Rubric", body.get("rubric_name")) + _add("Grading system", body.get("grading_system")) + _add("Access type", body.get("access_type")) + + if not rows: + return "" + return "EXAM BRIEF — follow these parameters strictly:\n" + "\n".join(rows) + + def _build_rag_query(self, body, fallback=""): + """Combine topic / category / type / subject / objective into a single + semantic query for vector RAG (much more specific than just 'topic').""" + parts = [] + for key in ("topic", "topics", "category", "passage_type", "type", + "task_type", "section_type", "subject_name", "module"): + v = body.get(key) + if isinstance(v, list): + parts.extend(str(x) for x in v if x) + elif v: + parts.append(str(v)) + if not parts and fallback: + parts.append(fallback) + return " ".join(parts)[:400] + def _generate_passage(self, ai, body): topic = body.get("topic", "general knowledge") difficulty = body.get("difficulty", "B2") - word_count = body.get("word_count", 300) - messages = [ - {"role": "system", "content": ( - f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. " - "The passage should be suitable for an English language exam. " - 'Return JSON: {"passage": "the full passage text", "title": "passage title"}' - )}, - {"role": "user", "content": f"Topic: {topic}"}, - ] + word_count = int(body.get("word_count") or 300) + passage_type = (body.get("passage_type") or body.get("type") or "academic").lower() + category = body.get("category", "") + exam_mode = body.get("exam_mode", "official") + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + persona = self._persona_for("reading", exam_mode, passage_type) + persona_ctx = self._build_persona_context({**body, "module": "reading"}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + system_parts = [persona] + if cefr: + system_parts.append("CEFR TARGET LEVEL\n" + cefr) + system_parts.append( + f"TASK\nWrite ONE {passage_type} reading passage of approximately {word_count} words " + f"(±10%). The passage must be authentic-feeling, coherent, and pitched exactly at " + f"CEFR {difficulty}. Sentence length, clause complexity, lexical range and cohesion " + f"markers must match the target band. Avoid regional slang. Do NOT mention CEFR, IELTS " + f"or grading inside the passage itself." + ) + if category: + system_parts.append(f"DOMAIN / CATEGORY\nThe passage must be grounded in the domain: '{category}'.") + system_parts.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"title": "short catchy title (<=10 words)",' + ' "passage": "the full passage text, use \\n\\n between paragraphs",' + ' "paragraph_count": int,' + ' "approx_word_count": int,' + ' "key_vocabulary": [string, ...],' + ' "rhetorical_structure": "e.g. problem-solution / compare-contrast / chronological",' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(system_parts)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — use these curated EnCoach resources to ground the " + "passage in what learners have been studying. Match terminology, examples and " + "scope; DO NOT copy verbatim:\n\n" + material_context + )}) + messages.append({"role": "user", "content": f"Topic: {topic}"}) return _json_response(ai.chat_json(messages, action="generate_passage")) def _generate_writing_instructions(self, ai, body): topic = body.get("topic", "general") - difficulty = body.get("difficulty", "A1") - task_type = body.get("task_type", "letter") - messages = [ - {"role": "system", "content": ( - f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. " - "Include clear instructions that tell the student what to write about. " - 'Return JSON: {"instructions": "the full instructions text"}' - )}, - {"role": "user", "content": f"Topic: {topic}"}, - ] + difficulty = body.get("difficulty", "B2") + task_type = body.get("task_type", "essay") + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + word_limit = int(body.get("word_limit") or (250 if task_type == "essay" else 150)) + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + rubric_name = body.get("rubric_name", "") + + persona = self._persona_for("writing", exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": "writing"}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + sys.append( + f"TASK\nWrite the STUDENT-FACING instructions for a '{task_type}' writing task at " + f"CEFR {difficulty}. The task must be completable in roughly {word_limit} words. " + "Use a neutral, authoritative exam register. Include any bullet-point sub-prompts a " + "student needs to address (e.g. 'explain', 'describe', 'suggest'). Do NOT write the " + "model answer — only the instructions." + ) + if rubric_name: + sys.append( + f"RUBRIC AWARENESS\nThe student answer will be graded with the rubric " + f"'{rubric_name}'. Frame the instructions so that the task naturally elicits the " + "criteria that rubric evaluates (task response, coherence, lexical resource, " + "grammatical range)." + ) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"instructions": "the full student-facing instructions (plain text, \\n for line breaks)",' + ' "suggested_word_limit": int,' + ' "evaluation_focus": [string, ...],' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — ground the task in topics students have studied:\n\n" + + material_context + )}) + messages.append({"role": "user", "content": f"Topic: {topic}"}) return _json_response(ai.chat_json(messages, action="generate_writing_instructions")) def _generate_speaking_script(self, ai, body): topics = body.get("topics", []) difficulty = body.get("difficulty", "B1") part = body.get("part", "speaking_1") - topic_str = ", ".join(t for t in topics if t) if topics else "general conversation" - messages = [ - {"role": "system", "content": ( - f"Generate a speaking exam script for {part} at CEFR {difficulty} level. " - "Include examiner questions and prompts for the student. " - 'Return JSON: {"script": "the full script text"}' - )}, - {"role": "user", "content": f"Topics: {topic_str}"}, - ] + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + topic_str = ", ".join(t for t in topics if t) if topics else (body.get("topic") or "general conversation") + + persona = self._persona_for("speaking", exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": "speaking", "topics": topics, "topic": topic_str}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic_str) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + part_guidance = { + "speaking_1": "Part 1 — Introduction & Interview: 4-6 short personal warm-up questions (15-30s answers).", + "speaking_2": "Part 2 — Individual Long Turn: ONE cue card with 3-4 bullet points; 1 min prep, 1-2 min monologue; 2-3 examiner follow-ups.", + "speaking_3": "Part 3 — Two-way Discussion: 4-6 abstract/analytical questions linked to Part 2 theme (45-60s answers).", + "interactive": "Interactive role-play: a realistic conversational scenario with turns labelled.", + }.get(part, "Mixed speaking prompts") + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + sys.append("TASK\n" + part_guidance + ( + f" Level-pitch the questions so a {difficulty} candidate is genuinely stretched " + "but not overwhelmed. Label every line as 'Examiner:' or 'Candidate:' (for examples) " + "or keep to 'Examiner:' only if you prefer. Use neutral British English register." + )) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"script": "the full examiner script (plain text, use \\n for line breaks)",' + ' "follow_up_questions": [string, ...],' + ' "cue_card_bullets": [string, ...] // only for speaking_2, else [],' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — anchor the prompts in themes the candidate has studied:\n\n" + + material_context + )}) + messages.append({"role": "user", "content": f"Topics: {topic_str}"}) return _json_response(ai.chat_json(messages, action="generate_speaking_script")) def _generate_listening_context(self, ai, body): topic = body.get("topic", "everyday life") section_type = body.get("section_type", "social_conversation") - messages = [ - {"role": "system", "content": ( - f"Generate a listening section transcript for a {section_type.replace('_', ' ')} " - "in an English language exam. Include speaker labels. " - 'Return JSON: {"context": "the full conversation/monologue transcript"}' - )}, - {"role": "user", "content": f"Topic: {topic}"}, - ] + difficulty = body.get("difficulty", "B1") + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + persona = self._persona_for("listening", exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": "listening"}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=topic) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) + + section_guidance = { + "social_conversation": "two speakers in a day-to-day social context (2-4 min); include natural discourse markers, hesitations and back-channels.", + "social_monologue": "one speaker in a non-academic context, e.g. tour, announcement, instructions (2-3 min).", + "academic_conversation": "2-4 speakers in a university / study context (seminar, tutorial).", + "academic_lecture": "one lecturer delivering a ~4 min academic talk with clear signposting.", + }.get(section_type, f"a {section_type.replace('_', ' ')}") + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + sys.append( + "TASK\nWrite a listening transcript for " + section_guidance + + f" Pitch vocabulary, speed implications and idiomaticity to CEFR {difficulty}. " + "Label EVERY turn with a speaker tag (e.g. 'Presenter:', 'Student A:', 'Tutor:'). " + "Include realistic features like fillers ('er', 'you know'), self-corrections, " + "and conversational overlap where appropriate — but stay intelligible." + ) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"context": "the full labelled transcript",' + ' "speakers": [{"label": string, "description": string}],' + ' "approx_duration_seconds": int,' + ' "cefr_level": "' + difficulty + '"}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — weave in concepts students have encountered:\n\n" + + material_context + )}) + messages.append({"role": "user", "content": f"Topic: {topic}"}) return _json_response(ai.chat_json(messages, action="generate_listening_context")) def _generate_exercises(self, ai, module, body): @@ -567,42 +855,100 @@ class AIController(http.Controller): exercise_types = body.get("exercise_types", []) type_counts = body.get("type_counts", {}) type_instructions = body.get("type_instructions", {}) + type_difficulties = body.get("type_difficulties", {}) or {} default_count = body.get("count_per_type", 5) difficulty = body.get("difficulty", "B2") + exam_mode = body.get("exam_mode", "official") + exam_type = (body.get("passage_type") or body.get("type") or "academic").lower() + course_id = body.get("course_id") + subject_id = body.get("subject_id") + entity_id = body.get("entity_id") + + persona = self._persona_for(module if module in ("reading", "listening") else "reading", + exam_mode, exam_type) + persona_ctx = self._build_persona_context({**body, "module": module}) + cefr = self._CEFR_DESCRIPTORS.get(difficulty, "") + + rag_query = self._build_rag_query(body, fallback=passage_text[:200]) + material_context = self._get_material_context( + ai, rag_query, course_id=course_id, subject_id=subject_id, entity_id=entity_id + ) type_specs = [] total = 0 + level_set = set() for et in exercise_types: c = int(type_counts.get(et, default_count)) instr = type_instructions.get(et, "") - spec_line = f"- EXACTLY {c} questions of type \"{et}\"" + lvl = type_difficulties.get(et) or difficulty + level_set.add(lvl) + spec_line = f"- EXACTLY {c} questions of type \"{et}\" pitched at CEFR {lvl}" if instr: spec_line += f"\n Student instructions: \"{instr}\"" type_specs.append(spec_line) total += c - spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions" + spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions at CEFR {difficulty}" - messages = [ - {"role": "system", "content": ( - f"You are an exam question generator. Generate EXACTLY {total} exercises " - f"at CEFR {difficulty} level based on the passage below.\n\n" - f"REQUIRED question breakdown (you MUST follow these counts exactly):\n" - f"{spec_str}\n\n" - "CRITICAL RULES:\n" - f"1. The total number of questions in your response MUST be exactly {total}.\n" - "2. Each question MUST have a 'type' field set to one of the requested types.\n" - "3. Each question MUST include an 'instructions' field with the student-facing instructions " - "for that section (use the provided instructions, or write appropriate ones).\n" - "4. For mcq/true_false types: include 'options' array and 'correct_answer'.\n" - "5. For fill_blanks/write_blanks types: use '___' in the prompt for blanks, " - "set correct_answer to the missing word(s), options can be empty.\n" - "6. For paragraph_match: prompt describes what to match, options are paragraph labels.\n\n" - "Return JSON:\n" - '{"questions": [{"type": string, "instructions": string, "prompt": string, ' - '"options": [string], "correct_answer": string, "explanation": string, "marks": number}]}' - )}, - {"role": "user", "content": passage_text[:3000]}, - ] + extra_cefr_block = "" + if len(level_set) > 1: + extra_cefr_block = "PER-TYPE CEFR LEVELS\n" + "\n".join( + f"- {lvl}: {self._CEFR_DESCRIPTORS.get(lvl, '')}" for lvl in sorted(level_set) + ) + + sys = [persona] + if cefr: + sys.append("CEFR TARGET LEVEL\n" + cefr) + if extra_cefr_block: + sys.append(extra_cefr_block) + # How many items should require inferential/higher-order thinking + higher_order_target = 0 + if difficulty in ("B2", "C1", "C2"): + higher_order_target = max(1, total // 3) + sys.append( + f"TASK\nWrite EXACTLY {total} exam questions based strictly on the source text below. " + "Every question MUST be answerable from that text alone — no outside knowledge. " + "Distractors for MCQs must be plausible, drawn from or consistent with the text, and of " + "roughly equal length and grammatical form. Do not repeat stems. Vary cognitive load: " + "include both literal-retrieval and inferential items." + + (f" At least {higher_order_target} of the {total} items MUST require inference, " + "implication or synthesis rather than literal lookup." if higher_order_target else "") + + f"\n\nREQUIRED BREAKDOWN (respect counts and per-type CEFR exactly):\n{spec_str}" + ) + sys.append( + "TYPE RULES\n" + "1. mcq: 4 options, exactly one correct, 'options' is an array of strings, " + "'correct_answer' is the exact string of the correct option.\n" + "2. true_false: options = ['TRUE','FALSE','NOT GIVEN']; answer is one of those.\n" + "3. fill_blanks / write_blanks: put '___' inside 'prompt' where the blank is, " + "'correct_answer' is the filler word(s) taken verbatim from the text, 'options' = [].\n" + "4. paragraph_match / matching_headings: 'prompt' is the statement/heading, " + "'options' is the list of paragraph labels, 'correct_answer' is the correct label.\n" + "5. short_answer / summary_completion: 'options' = [], 'correct_answer' is the expected answer.\n" + "6. Every question MUST include 'source_paragraph' (1-indexed number of the paragraph " + "the answer is drawn from) and a non-empty 'explanation' that quotes or paraphrases " + "the supporting span from that paragraph." + ) + sys.append( + "OUTPUT CONTRACT — return ONLY this JSON:\n" + '{"questions": [{"type": string, "instructions": string, "prompt": string, ' + '"options": [string], "correct_answer": string, "explanation": string, ' + '"source_paragraph": int, "cognitive_level": "literal"|"inferential"|"evaluative", ' + '"marks": number, "cefr_level": string}]}' + ) + + messages = [{"role": "system", "content": "\n\n".join(sys)}] + if persona_ctx: + messages.append({"role": "system", "content": persona_ctx}) + if material_context: + messages.append({"role": "system", "content": ( + "REFERENCE MATERIAL — keep terminology and scope consistent with curriculum:\n\n" + + material_context + )}) + messages.append({ + "role": "user", + "content": "SOURCE TEXT (authoritative — every answer must come from here):\n\n" + + (passage_text[:3000] or "(no source text provided)") + }) return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}")) # ── Fallback generators (no OpenAI needed) ── @@ -906,7 +1252,7 @@ class AIController(http.Controller): return {"questions": questions} # ── POST /api/exam/generation/submit — create exam from generation page ── - @http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False) + @http.route("/api/exam/generation/submit", type="http", auth="public", methods=["POST"], csrf=False) def generation_submit(self, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -1023,6 +1369,11 @@ class AIController(http.Controller): question_ids = [] + def _q_difficulty_for(ex): + # Honor per-question CEFR if the AI emitted one (per-type difficulty). + ex_cefr = (ex.get("cefr_level") or "").strip().upper() + return CEFR_TO_DIFFICULTY.get(ex_cefr, q_difficulty) if ex_cefr else q_difficulty + passages = mod_data.get("passages") or [] for p_idx, passage in enumerate(passages): if passage.get("text"): @@ -1036,9 +1387,9 @@ class AIController(http.Controller): "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", ""), + "correct_answer": ex.get("correct_answer", "") or "", "marks": float(ex.get("marks", 1)), - "difficulty": q_difficulty, + "difficulty": _q_difficulty_for(ex), "status": "active", "ai_generated": True, }) @@ -1057,9 +1408,9 @@ class AIController(http.Controller): "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", ""), + "correct_answer": ex.get("correct_answer", "") or "", "marks": float(ex.get("marks", 1)), - "difficulty": q_difficulty, + "difficulty": _q_difficulty_for(ex), "status": "active", "ai_generated": True, }) @@ -1115,7 +1466,7 @@ class AIController(http.Controller): return _json_response({"error": str(e)}, 500) # ── POST /api/ai/batch-optimize/apply — persist batch optimization ── - @http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/ai/batch-optimize/apply", type="http", auth="public", methods=["POST"], csrf=False) def ai_batch_optimize_apply(self, **kw): body = _get_json() optimized = body.get("optimized", []) @@ -1130,7 +1481,7 @@ class AIController(http.Controller): return _json_response({"applied": 0, "error": str(e)}, 500) # ── POST /api/exam//generate/save — save generated exam items ── - @http.route("/api/exam//generate/save", type="http", auth="none", methods=["POST"], csrf=False) + @http.route("/api/exam//generate/save", type="http", auth="public", methods=["POST"], csrf=False) def exam_generate_save(self, module, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() @@ -1170,7 +1521,7 @@ class AIController(http.Controller): return _json_response({"saved": 0, "error": str(e)}, 500) # ── POST /api/workbench/suggest-materials — AI material suggestions ── - @http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/workbench/suggest-materials", type="http", auth="public", methods=["POST"], csrf=False) def workbench_suggest_materials(self, **kw): body = _get_json() try: @@ -1190,7 +1541,7 @@ class AIController(http.Controller): return _json_response({"materials": [], "error": str(e)}) # ── Topic content generation — adaptive ── - @http.route("/api/topics//generate-content", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/topics//generate-content", type="http", auth="public", methods=["POST"], csrf=False) def topic_generate_content(self, topic_id, **kw): body = _get_json() try: diff --git a/custom_addons/encoach_ai/controllers/coach_controller.py b/custom_addons/encoach_ai/controllers/coach_controller.py index 47e5a19..4f0a988 100644 --- a/custom_addons/encoach_ai/controllers/coach_controller.py +++ b/custom_addons/encoach_ai/controllers/coach_controller.py @@ -27,7 +27,7 @@ class CoachController(http.Controller): return CoachService(request.env) # ── POST /api/coach/chat — AiAssistantDrawer.tsx ── - @http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/chat", type="http", auth="public", methods=["POST"], csrf=False) def coach_chat(self, **kw): body = _get_json() try: @@ -43,7 +43,7 @@ class CoachController(http.Controller): return _json_response({"reply": f"I'm having trouble right now. Error: {e}"}) # ── GET /api/coach/tip — AiTipBanner.tsx ── - @http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/coach/tip", type="http", auth="public", methods=["GET"], csrf=False) def coach_tip(self, **kw): context = request.params.get("context", "general") try: @@ -53,7 +53,7 @@ class CoachController(http.Controller): return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"}) # ── POST /api/coach/explain — AiGradeExplainer.tsx ── - @http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/explain", type="http", auth="public", methods=["POST"], csrf=False) def coach_explain(self, **kw): body = _get_json() try: @@ -67,7 +67,7 @@ class CoachController(http.Controller): return _json_response({"explanation": f"Could not generate explanation: {e}"}) # ── POST /api/coach/suggest — AiStudyCoach.tsx ── - @http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/suggest", type="http", auth="public", methods=["POST"], csrf=False) def coach_suggest(self, **kw): body = _get_json() try: @@ -82,7 +82,7 @@ class CoachController(http.Controller): }) # ── POST /api/coach/writing-help — AiWritingHelper.tsx ── - @http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/writing-help", type="http", auth="public", methods=["POST"], csrf=False) def coach_writing_help(self, **kw): body = _get_json() try: @@ -97,7 +97,7 @@ class CoachController(http.Controller): return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]}) # ── POST /api/coach/hint — (unused component, wired for completeness) ── - @http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/coach/hint", type="http", auth="public", methods=["POST"], csrf=False) def coach_hint(self, **kw): body = _get_json() try: diff --git a/custom_addons/encoach_ai/controllers/media_controller.py b/custom_addons/encoach_ai/controllers/media_controller.py index e470cb2..bee168b 100644 --- a/custom_addons/encoach_ai/controllers/media_controller.py +++ b/custom_addons/encoach_ai/controllers/media_controller.py @@ -52,7 +52,7 @@ class MediaController(http.Controller): ) # ── POST /api/exam/listening/media — generate listening audio ── - @http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/listening/media", type="http", auth="public", methods=["POST"], csrf=False) def listening_media(self, **kw): body = _get_json() text = body.get("text", "") @@ -72,7 +72,7 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── POST /api/exam/speaking/media — generate speaking prompt audio ── - @http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/speaking/media", type="http", auth="public", methods=["POST"], csrf=False) def speaking_media(self, **kw): body = _get_json() text = body.get("text", "") @@ -89,7 +89,7 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── GET /api/exam/avatars — list ELAI avatars ── - @http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/exam/avatars", type="http", auth="public", methods=["GET"], csrf=False) def list_avatars(self, **kw): try: from odoo.addons.encoach_ai.services.elai_service import ElaiService @@ -100,7 +100,7 @@ class MediaController(http.Controller): return _json_response({"avatars": [], "note": str(e)}) # ── POST /api/exam/avatar/video — create avatar video ── - @http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/exam/avatar/video", type="http", auth="public", methods=["POST"], csrf=False) def create_avatar_video(self, **kw): body = _get_json() try: @@ -116,7 +116,7 @@ class MediaController(http.Controller): return _json_response({"error": str(e)}, 500) # ── GET /api/exam/avatar/video/:id — check video status ── - @http.route("/api/exam/avatar/video/", type="http", auth="user", methods=["GET"], csrf=False) + @http.route("/api/exam/avatar/video/", type="http", auth="public", methods=["GET"], csrf=False) def video_status(self, video_id, **kw): try: from odoo.addons.encoach_ai.services.elai_service import ElaiService @@ -126,7 +126,7 @@ class MediaController(http.Controller): return _json_response({"video_id": video_id, "status": "error", "error": str(e)}) # ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ── - @http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False) + @http.route("/api/courses/ai-generate", type="http", auth="public", methods=["POST"], csrf=False) def ai_generate_course(self, **kw): body = _get_json() try: diff --git a/custom_addons/encoach_core/models/permission.py b/custom_addons/encoach_core/models/permission.py index a6d6be7..54245e1 100644 --- a/custom_addons/encoach_core/models/permission.py +++ b/custom_addons/encoach_core/models/permission.py @@ -6,7 +6,9 @@ class EncoachPermission(models.Model): _description = "EnCoach Permission" code = fields.Char(required=True, index=True) - topic = fields.Char() + name = fields.Char() + description = fields.Text() + topic = fields.Char(string="Category") legacy_id = fields.Char(index=True) _code_unique = models.Constraint( diff --git a/custom_addons/encoach_core/models/role.py b/custom_addons/encoach_core/models/role.py index 98c7e49..7e46030 100644 --- a/custom_addons/encoach_core/models/role.py +++ b/custom_addons/encoach_core/models/role.py @@ -1,4 +1,4 @@ -from odoo import models, fields +from odoo import models, fields, api class EncoachRole(models.Model): @@ -6,10 +6,22 @@ class EncoachRole(models.Model): _description = "EnCoach Role" name = fields.Char(required=True) + code = fields.Char(index=True) + description = fields.Text() entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") permission_ids = fields.Many2many("encoach.permission", string="Permissions") + user_ids = fields.Many2many( + "res.users", "encoach_role_user_rel", "role_id", "user_id", + string="Users", + ) is_default = fields.Boolean(default=False) legacy_id = fields.Char(index=True) + user_count = fields.Integer(compute="_compute_user_count", store=True) + + @api.depends("user_ids") + def _compute_user_count(self): + for rec in self: + rec.user_count = len(rec.user_ids) def to_encoach_dict(self): self.ensure_one() diff --git a/custom_addons/encoach_exam_template/controllers/approval_workflows.py b/custom_addons/encoach_exam_template/controllers/approval_workflows.py index d891ab3..d78ab84 100644 --- a/custom_addons/encoach_exam_template/controllers/approval_workflows.py +++ b/custom_addons/encoach_exam_template/controllers/approval_workflows.py @@ -15,6 +15,14 @@ def _cr(): return request.env.cr +def _table_exists(cr, name): + cr.execute( + "SELECT to_regclass(%s) IS NOT NULL", (f"public.{name}",) + ) + row = cr.fetchone() + return bool(row and row[0]) + + def _user_name(cr, user_id): if not user_id: return '' @@ -34,6 +42,8 @@ class ApprovalWorkflowController(http.Controller): def list_workflows(self, **kw): try: cr = _cr() + if not _table_exists(cr, 'encoach_approval_workflow'): + return _json_response({'items': [], 'total': 0}) cr.execute( "SELECT id, name, type, allow_bypass, entity_id, create_date " "FROM encoach_approval_workflow ORDER BY create_date DESC") @@ -145,6 +155,8 @@ class ApprovalWorkflowController(http.Controller): def list_requests(self, **kw): try: cr = _cr() + if not _table_exists(cr, 'encoach_approval_request'): + return _json_response({'items': [], 'total': 0}) state_filter = kw.get('state') where = "" params = [] diff --git a/custom_addons/encoach_exam_template/controllers/entities.py b/custom_addons/encoach_exam_template/controllers/entities.py index 254e98a..6ce01d3 100644 --- a/custom_addons/encoach_exam_template/controllers/entities.py +++ b/custom_addons/encoach_exam_template/controllers/entities.py @@ -1,34 +1,175 @@ -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, + jwt_required, _json_response, _error_response, _get_json_body, ) _logger = logging.getLogger(__name__) +ENTITY_MODEL = 'encoach.entity' + + +def _entity_to_dict(entity): + d = entity.to_api_dict() if hasattr(entity, 'to_api_dict') else { + 'id': entity.id, + 'name': entity.name, + 'code': getattr(entity, 'code', '') or '', + 'type': getattr(entity, 'type', '') or '', + 'active': entity.active if hasattr(entity, 'active') else True, + 'user_count': getattr(entity, 'user_count', 0) or 0, + } + d['role_count'] = len(entity.role_ids) if hasattr(entity, 'role_ids') else 0 + return d + + +def _role_to_dict(role): + return { + 'id': role.id, + 'name': role.name, + 'code': getattr(role, 'code', '') or '', + 'entity_id': role.entity_id.id if hasattr(role, 'entity_id') and role.entity_id else None, + 'permission_ids': role.permission_ids.ids if hasattr(role, 'permission_ids') else [], + 'user_count': len(role.user_ids) if hasattr(role, 'user_ids') else 0, + } + class EntityController(http.Controller): - @http.route('/api/entities', type='http', auth='none', + @http.route('/api/entities', type='http', auth='public', 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)}) + M = request.env[ENTITY_MODEL].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('type'): + domain.append(('type', '=', kw['type'])) + recs = M.search(domain, order='name') + items = [_entity_to_dict(r) for r in recs] + return _json_response({ + 'data': items, + 'items': items, + 'total': len(items), + }) except Exception as e: _logger.exception('list entities failed') - return _json_response({'error': str(e)}, 500) + return _error_response(str(e), 500) + + @http.route('/api/entities/', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def get_entity(self, entity_id, **kw): + try: + entity = request.env[ENTITY_MODEL].sudo().browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + return _json_response({'data': _entity_to_dict(entity)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/entities', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_entity(self, **kw): + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('Name is required', 400) + + code = body.get('code', '').strip() + if not code: + code = name.upper().replace(' ', '_')[:20] + + vals = {'name': name, 'code': code} + for field in ('type', 'primary_color', 'secondary_color', + 'background_color', 'login_title', + 'login_description', 'results_release_mode'): + if field in body: + vals[field] = body[field] + + try: + entity = request.env[ENTITY_MODEL].sudo().create(vals) + return _json_response({'data': _entity_to_dict(entity)}, 201) + except Exception as e: + _logger.exception('Error creating entity') + msg = str(e) + if 'unique' in msg.lower() or 'duplicate' in msg.lower(): + return _error_response( + f'An entity with code "{code}" already exists', 409) + return _error_response(msg, 500) + + @http.route('/api/entities/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_entity(self, entity_id, **kw): + body = _get_json_body() + try: + entity = request.env[ENTITY_MODEL].sudo().browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + + vals = {} + for field in ('name', 'code', 'type', 'primary_color', + 'secondary_color', 'background_color', + 'login_title', 'login_description', + 'results_release_mode', 'active'): + if field in body: + vals[field] = body[field] + if vals: + entity.write(vals) + return _json_response({'data': _entity_to_dict(entity)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/entities/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_entity(self, entity_id, **kw): + try: + entity = request.env[ENTITY_MODEL].sudo().browse(entity_id) + if not entity.exists(): + return _error_response('Entity not found', 404) + entity.sudo().unlink() + return _json_response({'success': True}) + except Exception as e: + msg = str(e) + if 'foreign key' in msg.lower() or 'restrict' in msg.lower(): + return _error_response( + 'Cannot delete: entity has linked users or roles', 409) + return _error_response(msg, 500) + + @http.route('/api/entities//roles', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_entity_roles(self, entity_id, **kw): + try: + roles = request.env['encoach.role'].sudo().search([ + ('entity_id', '=', entity_id) + ]) + return _json_response({ + 'data': [_role_to_dict(r) for r in roles], + 'total': len(roles), + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/entities//roles', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_entity_role(self, entity_id, **kw): + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('Role name is required', 400) + vals = {'name': name, 'entity_id': entity_id} + if body.get('permission_ids'): + vals['permission_ids'] = [(6, 0, [int(p) for p in body['permission_ids']])] + try: + role = request.env['encoach.role'].sudo().create(vals) + return _json_response({'data': _role_to_dict(role)}, 201) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/__init__.py b/custom_addons/encoach_lms_api/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/custom_addons/encoach_lms_api/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/custom_addons/encoach_lms_api/__manifest__.py b/custom_addons/encoach_lms_api/__manifest__.py new file mode 100644 index 0000000..97a5f19 --- /dev/null +++ b/custom_addons/encoach_lms_api/__manifest__.py @@ -0,0 +1,32 @@ +{ + 'name': 'EnCoach LMS API', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'REST API gateway for all LMS frontend pages', + 'author': 'EnCoach', + 'depends': [ + 'base', + 'mail', + 'openeducat_core', + 'openeducat_classroom', + 'openeducat_timetable', + 'openeducat_attendance', + 'openeducat_activity', + 'openeducat_admission', + 'openeducat_assignment', + 'openeducat_exam', + 'openeducat_facility', + 'openeducat_fees', + 'openeducat_library', + 'encoach_core', + 'encoach_api', + 'encoach_taxonomy', + 'encoach_resources', + ], + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/custom_addons/encoach_lms_api/controllers/__init__.py b/custom_addons/encoach_lms_api/controllers/__init__.py new file mode 100644 index 0000000..4414c62 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/__init__.py @@ -0,0 +1,26 @@ +from . import lms_core +from . import academic +from . import timetable +from . import attendance +from . import grades +from . import admissions +from . import fees +from . import library +from . import activities +from . import facilities +from . import lessons +from . import student_leave +from . import student_progress +from . import communication +from . import institutional_exams +from . import resources +from . import users_roles +from . import classrooms +from . import courseware +from . import notifications +from . import faq +from . import stats +from . import tickets +from . import payments +from . import platform_settings +from . import training diff --git a/custom_addons/encoach_lms_api/controllers/academic.py b/custom_addons/encoach_lms_api/controllers/academic.py new file mode 100644 index 0000000..0f60944 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/academic.py @@ -0,0 +1,364 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_year(r): + terms = [] + if hasattr(r, 'academic_term_ids'): + for t in r.academic_term_ids: + terms.append(_ser_term(t)) + return { + 'id': r.id, + 'name': r.name or '', + 'start_date': str(r.start_date) if r.start_date else '', + 'end_date': str(r.end_date) if r.end_date else '', + 'term_structure': getattr(r, 'term_structure', 'others') or 'others', + 'terms': terms, + } + + +def _ser_term(t): + return { + 'id': t.id, + 'name': t.name or '', + 'term_start_date': str(t.term_start_date) if hasattr(t, 'term_start_date') and t.term_start_date else str(t.start_date) if hasattr(t, 'start_date') and t.start_date else '', + 'term_end_date': str(t.term_end_date) if hasattr(t, 'term_end_date') and t.term_end_date else str(t.end_date) if hasattr(t, 'end_date') and t.end_date else '', + 'academic_year_id': t.academic_year_id.id if hasattr(t, 'academic_year_id') and t.academic_year_id else 0, + 'academic_year_name': t.academic_year_id.name if hasattr(t, 'academic_year_id') and t.academic_year_id else '', + 'parent_term_id': t.parent_id.id if hasattr(t, 'parent_id') and t.parent_id else None, + 'parent_term_name': t.parent_id.name if hasattr(t, 'parent_id') and t.parent_id else None, + } + + +def _ser_dept(d): + env = d.env + course_count = 0 + faculty_count = 0 + try: + if 'op.course' in env: + Course = env['op.course'].sudo() + if 'department_id' in Course._fields: + course_count = Course.search_count([('department_id', '=', d.id)]) + if 'op.faculty' in env: + Faculty = env['op.faculty'].sudo() + if 'department_id' in Faculty._fields: + faculty_count = Faculty.search_count([('department_id', '=', d.id)]) + elif 'main_department_id' in Faculty._fields: + faculty_count = Faculty.search_count([('main_department_id', '=', d.id)]) + except Exception: + pass + return { + 'id': d.id, + 'name': d.name or '', + 'code': d.code or '' if hasattr(d, 'code') else '', + 'parent_id': d.parent_id.id if hasattr(d, 'parent_id') and d.parent_id else None, + 'parent_name': d.parent_id.name if hasattr(d, 'parent_id') and d.parent_id else None, + 'course_count': course_count, + 'faculty_count': faculty_count, + } + + +class AcademicController(http.Controller): + + # ── Academic Years ─────────────────────────────────────────────── + + @http.route('/api/academic-years', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_years(self, **kw): + try: + M = request.env['op.academic.year'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + return _json_response({'items': [_ser_year(r) for r in recs], 'data': [_ser_year(r) for r in recs], 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_years') + return _error_response(str(e), 500) + + @http.route('/api/academic-years/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_year(self, yid, **kw): + try: + rec = request.env['op.academic.year'].sudo().browse(yid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_year(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-years', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_year(self, **kw): + try: + body = _get_json_body() + Year = request.env['op.academic.year'].sudo() + vals = { + 'name': body.get('name', ''), + 'start_date': body.get('start_date'), + 'end_date': body.get('end_date'), + } + if body.get('term_structure') and 'term_structure' in Year._fields: + vals['term_structure'] = body['term_structure'] + rec = Year.create(vals) + return _json_response(_ser_year(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-years/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_year(self, yid, **kw): + try: + rec = request.env['op.academic.year'].sudo().browse(yid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + if 'term_structure' in body and 'term_structure' in rec._fields: + vals['term_structure'] = body['term_structure'] + if vals: + rec.write(vals) + return _json_response(_ser_year(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-years/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_year(self, yid, **kw): + try: + rec = request.env['op.academic.year'].sudo().browse(yid) + if rec.exists(): + # Unlink dependent terms first to avoid RESTRICT FK violation. + if hasattr(rec, 'academic_term_ids') and rec.academic_term_ids: + rec.academic_term_ids.unlink() + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_year') + return _error_response(str(e), 500) + + @http.route('/api/academic-years//generate-terms', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def generate_terms(self, yid, **kw): + """Create a set of op.academic.term records spanning the year. + The number of terms is dictated by the year's `term_structure`: + + two_sem → 2 semesters + two_sem_qua → 2 semesters + 4 quarters (nested under each sem) + three_sem → 3 semesters + four_Quarter → 4 quarters + final_year → 1 single term + others → 2 semesters (default fallback) + """ + try: + from datetime import timedelta + year = request.env['op.academic.year'].sudo().browse(yid) + if not year.exists(): + return _error_response('Not found', 404) + start = year.start_date + end = year.end_date + if not start or not end: + return _error_response('Year has no dates', 400) + total_days = max(1, (end - start).days) + structure = getattr(year, 'term_structure', 'two_sem') or 'two_sem' + body = _get_json_body() or {} + if body.get('replace'): + for existing in year.academic_term_ids: + existing.sudo().unlink() + + Term = request.env['op.academic.term'].sudo() + + def _split(n, label): + chunk = total_days // n + out = [] + for i in range(n): + s = start + timedelta(days=i * chunk) + e = end if i == n - 1 else start + timedelta(days=(i + 1) * chunk - 1) + out.append(Term.create({ + 'name': f'{year.name} - {label} {i + 1}', + 'term_start_date': str(s), + 'term_end_date': str(e), + 'academic_year_id': year.id, + })) + return out + + created = [] + if structure == 'two_sem': + created = _split(2, 'Semester') + elif structure == 'three_sem': + created = _split(3, 'Semester') + elif structure == 'four_Quarter': + created = _split(4, 'Quarter') + elif structure == 'final_year': + created = [Term.create({ + 'name': f'{year.name} - Final Year', + 'term_start_date': str(start), + 'term_end_date': str(end), + 'academic_year_id': year.id, + })] + elif structure == 'two_sem_qua': + sems = _split(2, 'Semester') + quarters = [] + for parent_idx, parent in enumerate(sems): + s = parent.term_start_date + e = parent.term_end_date + span = max(1, (e - s).days) + half = span // 2 + q1 = Term.create({ + 'name': f'{year.name} - Q{parent_idx * 2 + 1}', + 'term_start_date': str(s), + 'term_end_date': str(s + timedelta(days=half)), + 'academic_year_id': year.id, + }) + q2 = Term.create({ + 'name': f'{year.name} - Q{parent_idx * 2 + 2}', + 'term_start_date': str(s + timedelta(days=half + 1)), + 'term_end_date': str(e), + 'academic_year_id': year.id, + }) + if 'parent_id' in Term._fields: + q1.write({'parent_id': parent.id}) + q2.write({'parent_id': parent.id}) + quarters += [q1, q2] + created = sems + quarters + else: + created = _split(2, 'Semester') + + year.invalidate_recordset() + return _json_response([_ser_term(t) for t in created]) + except Exception as e: + _logger.exception('generate_terms') + return _error_response(str(e), 500) + + # ── Academic Terms ─────────────────────────────────────────────── + + @http.route('/api/academic-terms', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_terms(self, **kw): + try: + M = request.env['op.academic.term'].sudo() + domain = [] + if kw.get('year_id'): + domain.append(('academic_year_id', '=', int(kw['year_id']))) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({'items': [_ser_term(r) for r in recs], 'data': [_ser_term(r) for r in recs], 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-terms', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_term(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'term_start_date': body.get('term_start_date'), + 'term_end_date': body.get('term_end_date'), + } + if body.get('academic_year_id'): + vals['academic_year_id'] = int(body['academic_year_id']) + rec = request.env['op.academic.term'].sudo().create(vals) + return _json_response(_ser_term(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-terms/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_term(self, tid, **kw): + try: + rec = request.env['op.academic.term'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'term_start_date', 'term_end_date'): + if k in body: + vals[k] = body[k] + if 'academic_year_id' in body: + vals['academic_year_id'] = int(body['academic_year_id']) + if vals: + rec.write(vals) + return _json_response(_ser_term(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/academic-terms/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_term(self, tid, **kw): + try: + rec = request.env['op.academic.term'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Departments ────────────────────────────────────────────────── + + @http.route('/api/departments', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_depts(self, **kw): + try: + M = request.env['op.department'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + return _json_response({'items': [_ser_dept(r) for r in recs], 'data': [_ser_dept(r) for r in recs], 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/departments', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_dept(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + if body.get('parent_id'): + vals['parent_id'] = int(body['parent_id']) + rec = request.env['op.department'].sudo().create(vals) + return _json_response(_ser_dept(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/departments/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_dept(self, did, **kw): + try: + rec = request.env['op.department'].sudo().browse(did) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code'): + if k in body: + vals[k] = body[k] + if 'parent_id' in body: + vals['parent_id'] = int(body['parent_id']) if body['parent_id'] else False + if vals: + rec.write(vals) + return _json_response(_ser_dept(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/departments/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_dept(self, did, **kw): + try: + rec = request.env['op.department'].sudo().browse(did) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/activities.py b/custom_addons/encoach_lms_api/controllers/activities.py new file mode 100644 index 0000000..4fb681f --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/activities.py @@ -0,0 +1,117 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class ActivitiesController(http.Controller): + + @http.route('/api/activities', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_activities(self, **kw): + try: + M = request.env['op.activity'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if r.student_id else 0, + 'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '', + 'type_id': r.type_id.id if r.type_id else 0, + 'type_name': r.type_id.name if r.type_id else '', + 'date': str(r.date) if hasattr(r, 'date') and r.date else '', + 'description': getattr(r, 'description', '') or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activities', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_activity(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + if body.get('type_id'): + vals['type_id'] = int(body['type_id']) + if body.get('date'): + vals['date'] = body['date'] + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['op.activity'].sudo().create(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activities/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_activity(self, aid, **kw): + try: + rec = request.env['op.activity'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'description' in body: + vals['description'] = body['description'] + if 'date' in body: + vals['date'] = body['date'] + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activities/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_activity(self, aid, **kw): + try: + rec = request.env['op.activity'].sudo().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Activity Types ─────────────────────────────────────────────── + + @http.route('/api/activity-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_types(self, **kw): + try: + M = request.env['op.activity.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{'id': r.id, 'name': r.name or ''} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activity-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['op.activity.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/activity-types/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_type(self, tid, **kw): + try: + rec = request.env['op.activity.type'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/admissions.py b/custom_addons/encoach_lms_api/controllers/admissions.py new file mode 100644 index 0000000..cd5878a --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/admissions.py @@ -0,0 +1,314 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_register(r): + return { + 'id': r.id, + 'name': r.name or '', + 'course_id': r.course_id.id if r.course_id else 0, + 'course_name': r.course_id.name if r.course_id else '', + 'start_date': str(r.start_date) if r.start_date else '', + 'end_date': str(r.end_date) if r.end_date else '', + 'min_count': r.min_count if hasattr(r, 'min_count') else 0, + 'max_count': r.max_count if hasattr(r, 'max_count') else 0, + 'application_count': len(r.admission_ids) if hasattr(r, 'admission_ids') else 0, + 'state': r.state or 'draft', + } + + +def _ser_admission(a): + nationality = getattr(a, 'nationality', False) + nationality_name = '' + nationality_id = None + if nationality: + nationality_id = nationality.id + nationality_name = nationality.name or '' + return { + 'id': a.id, + 'name': a.name or '', + 'application_number': getattr(a, 'application_number', '') or str(a.id), + 'register_id': a.register_id.id if hasattr(a, 'register_id') and a.register_id else 0, + 'register_name': a.register_id.name if hasattr(a, 'register_id') and a.register_id else '', + 'course_id': a.course_id.id if a.course_id else 0, + 'course_name': a.course_id.name if a.course_id else '', + 'batch_id': a.batch_id.id if hasattr(a, 'batch_id') and a.batch_id else None, + 'batch_name': a.batch_id.name if hasattr(a, 'batch_id') and a.batch_id else None, + 'first_name': getattr(a, 'first_name', '') or a.name or '', + 'middle_name': getattr(a, 'middle_name', '') or '', + 'last_name': getattr(a, 'last_name', '') or '', + 'email': getattr(a, 'email', '') or '', + 'phone': getattr(a, 'phone', '') or '', + 'birth_date': str(a.birth_date) if hasattr(a, 'birth_date') and a.birth_date else None, + 'gender': a.gender if hasattr(a, 'gender') else 'o', + 'nationality_id': nationality_id, + 'nationality_name': nationality_name, + 'state': a.state or 'draft', + 'fees': getattr(a, 'fees', 0) or 0, + 'student_id': a.student_id.id if hasattr(a, 'student_id') and a.student_id else None, + 'created_at': str(a.create_date) if a.create_date else '', + } + + +class AdmissionsController(http.Controller): + + # ── Admission Registers ────────────────────────────────────────── + + @http.route('/api/admission-registers', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_registers(self, **kw): + try: + M = request.env['op.admission.register'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [_ser_register(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_register(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('start_date'): + vals['start_date'] = body['start_date'] + if body.get('end_date'): + vals['end_date'] = body['end_date'] + if body.get('min_count'): + vals['min_count'] = int(body['min_count']) + if body.get('max_count'): + vals['max_count'] = int(body['max_count']) + rec = request.env['op.admission.register'].sudo().create(vals) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + for k in ('min_count', 'max_count', 'course_id'): + if k in body: + vals[k] = int(body[k]) + if vals: + rec.write(vals) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers//confirm', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def confirm_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'action_confirm'): + rec.action_confirm() + else: + rec.write({'state': 'confirm'}) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers//close', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def close_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'done'}) + return _json_response(_ser_register(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admission-registers/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_register(self, rid, **kw): + try: + rec = request.env['op.admission.register'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Admissions ─────────────────────────────────────────────────── + + @http.route('/api/admissions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_admissions(self, **kw): + try: + M = request.env['op.admission'].sudo() + domain = [] + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + if kw.get('register_id'): + domain.append(('register_id', '=', int(kw['register_id']))) + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + q = (kw.get('q') or kw.get('search') or '').strip() + if q: + domain += ['|', '|', '|', '|', + ('name', 'ilike', q), + ('first_name', 'ilike', q), + ('last_name', 'ilike', q), + ('email', 'ilike', q), + ('application_number', 'ilike', q)] + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_admission(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_admission(self, **kw): + try: + from odoo import fields as odoo_fields + body = _get_json_body() + vals = { + 'name': f"{body.get('first_name', '')} {body.get('last_name', '')}".strip() + or body.get('name') or 'Applicant', + 'application_date': body.get('application_date') or odoo_fields.Date.today(), + } + if body.get('register_id'): + vals['register_id'] = int(body['register_id']) + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + for k in ('first_name', 'middle_name', 'last_name', 'email', 'phone', 'gender', 'birth_date'): + if body.get(k): + vals[k] = body[k] + if body.get('nationality_id') or body.get('nationality'): + vals['nationality'] = int(body.get('nationality_id') or body.get('nationality')) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + rec = request.env['op.admission'].sudo().create(vals) + return _json_response(_ser_admission(rec)) + except Exception as e: + _logger.exception('create_admission') + return _error_response(str(e), 500) + + @http.route('/api/admissions/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('first_name', 'middle_name', 'last_name', 'email', 'phone', + 'gender', 'birth_date', 'name'): + if k in body: + vals[k] = body[k] + for fk in ('register_id', 'course_id', 'batch_id'): + if fk in body and body[fk]: + vals[fk] = int(body[fk]) + if vals: + rec.write(vals) + return _json_response(_ser_admission(rec)) + except Exception as e: + _logger.exception('update_admission') + return _error_response(str(e), 500) + + @http.route('/api/admissions//submit', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def submit_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'submit_form'): + rec.submit_form() + else: + rec.write({'state': 'submit'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions//confirm', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def confirm_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'confirm_in_progress'): + rec.confirm_in_progress() + else: + rec.write({'state': 'confirm'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions//admit', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def admit(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'enroll_student'): + rec.enroll_student() + else: + rec.write({'state': 'admission'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/admissions//reject', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def reject_admission(self, aid, **kw): + try: + rec = request.env['op.admission'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'reject'}) + return _json_response(_ser_admission(rec)) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/attendance.py b/custom_addons/encoach_lms_api/controllers/attendance.py new file mode 100644 index 0000000..9bddf34 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/attendance.py @@ -0,0 +1,52 @@ +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 _ser_att(line): + sheet = line.attendance_id if hasattr(line, 'attendance_id') else None + student = line.student_id if hasattr(line, 'student_id') else None + return { + 'id': line.id, + 'date': str(sheet.attendance_date) if sheet and hasattr(sheet, 'attendance_date') and sheet.attendance_date else '', + 'course_id': sheet.register_id.course_id.id if sheet and hasattr(sheet, 'register_id') and sheet.register_id and sheet.register_id.course_id else 0, + 'course_name': sheet.register_id.course_id.name if sheet and hasattr(sheet, 'register_id') and sheet.register_id and sheet.register_id.course_id else '', + 'student_id': student.id if student else 0, + 'student_name': student.partner_id.name if student and student.partner_id else '', + 'status': line.present and 'present' or 'absent' if hasattr(line, 'present') else 'present', + } + + +class AttendanceController(http.Controller): + + @http.route('/api/attendance', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_attendance(self, **kw): + try: + M = request.env['op.attendance.line'].sudo() + domain = [] + if kw.get('student_id'): + domain.append(('student_id', '=', int(kw['student_id']))) + if kw.get('date'): + domain.append(('attendance_id.attendance_date', '=', kw['date'])) + recs = M.search(domain, limit=200, order='id desc') + data = [_ser_att(r) for r in recs] + return _json_response({'data': data}) + except Exception as e: + _logger.exception('list_attendance') + return _error_response(str(e), 500) + + @http.route('/api/attendance', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def record_attendance(self, **kw): + try: + body = _get_json_body() + _logger.info('record_attendance body: %s', body) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/classrooms.py b/custom_addons/encoach_lms_api/controllers/classrooms.py new file mode 100644 index 0000000..20cb107 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/classrooms.py @@ -0,0 +1,96 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_classroom(r): + return { + 'id': r.id, + 'name': r.name or '', + 'code': getattr(r, 'code', '') or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'capacity': getattr(r, 'capacity', 0) or 0, + } + + +class ClassroomsController(http.Controller): + + @http.route('/api/groups', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_groups(self, **kw): + try: + M = request.env['op.classroom'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [_ser_classroom(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_group(self, gid, **kw): + try: + rec = request.env['op.classroom'].sudo().browse(gid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_classroom(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_group(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + if body.get('capacity'): + vals['capacity'] = int(body['capacity']) + rec = request.env['op.classroom'].sudo().create(vals) + return _json_response(_ser_classroom(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_group(self, gid, **kw): + try: + rec = request.env['op.classroom'].sudo().browse(gid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups/transfer', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def transfer(self, **kw): + try: + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups//members', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def add_members(self, gid, **kw): + try: + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/groups//members/remove', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def remove_members(self, gid, **kw): + try: + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/communication.py b/custom_addons/encoach_lms_api/controllers/communication.py new file mode 100644 index 0000000..8db63eb --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/communication.py @@ -0,0 +1,402 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_board(b): + return { + 'id': b.id, + 'name': b.name or '', + 'course_id': b.course_id.id if b.course_id else 0, + 'course_name': b.course_id.name if b.course_id else '', + 'batch_id': b.batch_id.id if b.batch_id else None, + 'batch_name': b.batch_id.name if b.batch_id else None, + 'chapter_id': b.chapter_id.id if b.chapter_id else None, + 'chapter_name': b.chapter_id.name if b.chapter_id else None, + 'is_enabled': b.is_enabled, + 'allow_student_posts': b.allow_student_posts, + 'post_count': b.post_count or 0, + 'assignment_id': None, + } + + +def _ser_post(p): + return { + 'id': p.id, + 'board_id': p.board_id.id, + 'parent_id': p.parent_id.id if p.parent_id else None, + 'author_id': p.author_id.id, + 'author_name': p.author_id.name or '', + 'author_role': '', + 'title': p.title or '', + 'content': p.content or '', + 'attachment_ids': [], + 'attachment_names': [], + 'is_pinned': p.is_pinned, + 'is_resolved': p.is_resolved, + 'reply_count': len(p.child_ids), + 'created_at': str(p.create_date) if p.create_date else '', + } + + +def _ser_announcement(a): + return { + 'id': a.id, + 'title': a.title or '', + 'content': a.content or '', + 'author_id': a.author_id.id, + 'author_name': a.author_id.name or '', + 'course_id': a.course_id.id if a.course_id else None, + 'course_name': a.course_id.name if a.course_id else None, + 'batch_id': a.batch_id.id if a.batch_id else None, + 'batch_name': a.batch_id.name if a.batch_id else None, + 'priority': a.priority or 'normal', + 'is_published': a.is_published, + 'published_at': str(a.published_at) if a.published_at else None, + 'expires_at': str(a.expires_at) if a.expires_at else None, + 'send_email': a.send_email, + 'attachment_ids': [], + 'attachment_names': [], + } + + +def _ser_message(m): + return { + 'id': m.id, + 'sender_id': m.sender_id.id, + 'sender_name': m.sender_id.name or '', + 'recipient_id': m.recipient_id.id, + 'recipient_name': m.recipient_id.name or '', + 'subject': m.subject or '', + 'content': m.content or '', + 'is_read': m.is_read, + 'read_at': str(m.read_at) if m.read_at else None, + 'attachment_ids': [], + 'attachment_names': [], + 'send_email_copy': m.send_email_copy, + 'created_at': str(m.create_date) if m.create_date else '', + } + + +class CommunicationController(http.Controller): + + # ── Discussion Boards ──────────────────────────────────────────── + + @http.route('/api/discussion-boards', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_boards(self, **kw): + try: + M = request.env['encoach.discussion.board'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + recs = M.search(domain, limit=200, order='id desc') + return _json_response([_ser_board(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/discussion-boards', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_board(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + rec = request.env['encoach.discussion.board'].sudo().create(vals) + return _json_response(_ser_board(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/discussion-boards/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_board(self, bid, **kw): + try: + rec = request.env['encoach.discussion.board'].sudo().browse(bid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'is_enabled' in body: + vals['is_enabled'] = bool(body['is_enabled']) + if 'allow_student_posts' in body: + vals['allow_student_posts'] = bool(body['allow_student_posts']) + if vals: + rec.write(vals) + return _json_response(_ser_board(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Posts ──────────────────────────────────────────────────────── + + @http.route('/api/discussion-boards//posts', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_posts(self, bid, **kw): + try: + M = request.env['encoach.discussion.post'].sudo() + domain = [('board_id', '=', bid), ('parent_id', '=', False)] + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='is_pinned desc, create_date desc') + items = [_ser_post(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/discussion-boards//posts', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_post(self, bid, **kw): + try: + body = _get_json_body() + vals = { + 'board_id': bid, + 'author_id': request.env.uid, + 'content': body.get('content', ''), + } + if body.get('title'): + vals['title'] = body['title'] + if body.get('parent_id'): + vals['parent_id'] = int(body['parent_id']) + rec = request.env['encoach.discussion.post'].sudo().create(vals) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'content' in body: + vals['content'] = body['content'] + if 'title' in body: + vals['title'] = body['title'] + if vals: + rec.write(vals) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts//pin', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def pin_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + pinned = body.get('pinned', not rec.is_pinned) + rec.write({'is_pinned': pinned}) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/posts//resolve', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def resolve_post(self, pid, **kw): + try: + rec = request.env['encoach.discussion.post'].sudo().browse(pid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'is_resolved': True}) + return _json_response(_ser_post(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Announcements ──────────────────────────────────────────────── + + @http.route('/api/announcements', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_announcements(self, **kw): + try: + M = request.env['encoach.announcement'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('priority'): + domain.append(('priority', '=', kw['priority'])) + recs = M.search(domain, limit=200, order='create_date desc') + return _json_response([_ser_announcement(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_announcement(self, **kw): + try: + body = _get_json_body() + vals = { + 'title': body.get('title', ''), + 'content': body.get('content', ''), + 'author_id': request.env.uid, + 'priority': body.get('priority', 'normal'), + 'send_email': body.get('send_email', False), + } + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + if body.get('expires_at'): + vals['expires_at'] = body['expires_at'] + rec = request.env['encoach.announcement'].sudo().create(vals) + return _json_response(_ser_announcement(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_announcement(self, aid, **kw): + try: + rec = request.env['encoach.announcement'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('title', 'content', 'priority', 'expires_at'): + if k in body: + vals[k] = body[k] + if 'send_email' in body: + vals['send_email'] = bool(body['send_email']) + if vals: + rec.write(vals) + return _json_response(_ser_announcement(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_announcement(self, aid, **kw): + try: + rec = request.env['encoach.announcement'].sudo().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/announcements//publish', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def publish_announcement(self, aid, **kw): + try: + rec = request.env['encoach.announcement'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import datetime + rec.write({'is_published': True, 'published_at': str(datetime.now())}) + return _json_response(_ser_announcement(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Messages ───────────────────────────────────────────────────── + + @http.route('/api/messages', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_messages(self, **kw): + try: + M = request.env['encoach.message'].sudo() + uid = request.env.uid + domain = [('recipient_id', '=', uid)] + if kw.get('is_read') == 'false': + domain.append(('is_read', '=', False)) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='create_date desc') + items = [_ser_message(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/sent', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_sent_messages(self, **kw): + try: + M = request.env['encoach.message'].sudo() + uid = request.env.uid + offset, limit, page = _paginate(kw) + domain = [('sender_id', '=', uid)] + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='create_date desc') + items = [_ser_message(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def send_message(self, **kw): + try: + body = _get_json_body() + vals = { + 'sender_id': request.env.uid, + 'recipient_id': int(body.get('recipient_id', 0)), + 'subject': body.get('subject', ''), + 'content': body.get('content', ''), + 'send_email_copy': body.get('send_email_copy', False), + } + rec = request.env['encoach.message'].sudo().create(vals) + return _json_response(_ser_message(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_message(self, mid, **kw): + try: + rec = request.env['encoach.message'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + if not rec.is_read and rec.recipient_id.id == request.env.uid: + from datetime import datetime + rec.write({'is_read': True, 'read_at': str(datetime.now())}) + return _json_response(_ser_message(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_message(self, mid, **kw): + try: + rec = request.env['encoach.message'].sudo().browse(mid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/messages/unread-count', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def unread_count(self, **kw): + try: + count = request.env['encoach.message'].sudo().search_count([ + ('recipient_id', '=', request.env.uid), + ('is_read', '=', False), + ]) + return _json_response({'count': count}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/courseware.py b/custom_addons/encoach_lms_api/controllers/courseware.py new file mode 100644 index 0000000..41f39c0 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/courseware.py @@ -0,0 +1,613 @@ +import logging +import base64 +from odoo import http, fields +from odoo.http import request +from odoo.addons.encoach_api.controllers.base import ( + jwt_required, _json_response, _error_response, _get_json_body, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_chapter(c): + objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective']) + return { + 'id': c.id, + 'name': c.name or '', + 'course_id': c.course_id.id if c.course_id else 0, + 'course_name': c.course_id.name if c.course_id else '', + 'sequence': c.sequence, + 'description': c.description or '', + 'start_date': str(c.start_date) if c.start_date else None, + 'end_date': str(c.end_date) if c.end_date else None, + 'unlock_mode': c.unlock_mode or 'manual', + 'is_unlocked': c.is_unlocked, + 'topic_id': c.topic_id.id if c.topic_id else None, + 'topic_name': c.topic_id.name if c.topic_id else None, + 'domain_id': c.domain_id.id if c.domain_id else None, + 'domain_name': c.domain_id.name if c.domain_id else None, + 'learning_objective_ids': objectives.ids, + 'learning_objective_names': objectives.mapped('name'), + 'material_count': c.material_count or 0, + 'assignment_ids': [], + 'exam_ids': [], + } + + +def _ser_material(m): + return { + 'id': m.id, + 'name': m.name or '', + 'chapter_id': m.chapter_id.id, + 'type': m.type or 'pdf', + 'file_url': f'/api/materials/{m.id}/download' if m.file_data else None, + 'url': m.url or None, + 'description': m.description or None, + 'sequence': m.sequence, + 'allow_download': m.allow_download, + 'is_book': m.is_book, + 'book_chapters': m.book_chapters or None, + } + + +class CoursewareController(http.Controller): + + # ── Global Material Search ──────────────────────────────────────── + + @http.route('/api/materials/search', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def search_materials(self, **kw): + """Search all materials across all courses. Used by Admin Resources page.""" + try: + M = request.env['encoach.chapter.material'].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('type') and kw['type'] != 'all': + domain.append(('type', '=', kw['type'])) + if kw.get('course_id'): + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', int(kw['course_id'])) + ]) + domain.append(('chapter_id', 'in', chapters.ids)) + total = M.search_count(domain) + limit = min(int(kw.get('limit', 50)), 200) + offset = int(kw.get('offset', 0)) + recs = M.search(domain, limit=limit, offset=offset, order='id desc') + items = [] + for m in recs: + d = _ser_material(m) + d['course_id'] = m.chapter_id.course_id.id if m.chapter_id.course_id else 0 + d['course_name'] = m.chapter_id.course_id.name if m.chapter_id.course_id else '' + d['chapter_name'] = m.chapter_id.name if m.chapter_id else '' + d['source'] = 'course_material' + items.append(d) + return _json_response({'items': items, 'total': total}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Chapters ───────────────────────────────────────────────────── + + @http.route('/api/courses//chapters', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_chapters(self, course_id, **kw): + try: + M = request.env['encoach.course.chapter'].sudo() + recs = M.search([('course_id', '=', course_id)], order='sequence, id') + return _json_response([_ser_chapter(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/courses//chapters', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_chapter(self, course_id, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'course_id': course_id, + 'sequence': body.get('sequence', 10), + 'description': body.get('description', ''), + 'unlock_mode': body.get('unlock_mode', 'manual'), + } + if body.get('start_date'): + vals['start_date'] = body['start_date'] + if body.get('end_date'): + vals['end_date'] = body['end_date'] + if body.get('topic_id'): + vals['topic_id'] = int(body['topic_id']) + if body.get('learning_objective_ids'): + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + rec = request.env['encoach.course.chapter'].sudo().create(vals) + return _json_response(_ser_chapter(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_chapter(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'description', 'unlock_mode', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'topic_id' in body: + vals['topic_id'] = int(body['topic_id']) if body['topic_id'] else False + if 'is_unlocked' in body: + vals['is_unlocked'] = bool(body['is_unlocked']) + if 'learning_objective_ids' in body: + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + if vals: + rec.write(vals) + return _json_response(_ser_chapter(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//unlock', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def unlock_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'is_unlocked': True}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//lock', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def lock_chapter(self, cid, **kw): + try: + rec = request.env['encoach.course.chapter'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'is_unlocked': False}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/courses//chapters/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def reorder_chapters(self, course_id, **kw): + try: + body = _get_json_body() + ids = body.get('ids', []) + for seq, cid in enumerate(ids): + rec = request.env['encoach.course.chapter'].sudo().browse(int(cid)) + if rec.exists(): + rec.write({'sequence': seq * 10}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//progress', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_chapter_progress(self, cid, **kw): + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if student: + prog = request.env['encoach.chapter.progress'].sudo().search([ + ('chapter_id', '=', cid), ('student_id', '=', student.id) + ], limit=1) + if prog: + return _json_response({ + 'id': prog.id, + 'student_id': student.id, + 'student_name': student.partner_id.name or '', + 'chapter_id': cid, + 'status': prog.status, + 'started_at': str(prog.started_at) if prog.started_at else None, + 'completed_at': str(prog.completed_at) if prog.completed_at else None, + 'materials_completed': prog.materials_completed, + 'materials_total': prog.materials_total, + }) + return _json_response({ + 'id': 0, 'student_id': 0, 'student_name': '', 'chapter_id': cid, + 'status': 'not_started', 'started_at': None, 'completed_at': None, + 'materials_completed': 0, 'materials_total': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//progress/complete', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_chapter_complete(self, cid, **kw): + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _error_response('Student not found for current user', 404) + chapter = request.env['encoach.course.chapter'].sudo().browse(cid) + if not chapter.exists(): + return _error_response('Chapter not found', 404) + + Progress = request.env['encoach.chapter.progress'].sudo() + prog = Progress.search([ + ('chapter_id', '=', cid), + ('student_id', '=', student.id), + ], limit=1) + now = fields.Datetime.now() + if prog: + prog.write({ + 'status': 'completed', + 'completed_at': now, + 'materials_completed': chapter.material_count, + 'materials_total': chapter.material_count, + }) + else: + prog = Progress.create({ + 'chapter_id': cid, + 'student_id': student.id, + 'status': 'completed', + 'started_at': now, + 'completed_at': now, + 'materials_completed': chapter.material_count, + 'materials_total': chapter.material_count, + }) + + course_id = chapter.course_id.id + self._check_course_completion(student.id, course_id) + + return _json_response({ + 'success': True, + 'chapter_id': cid, + 'status': 'completed', + }) + except Exception as e: + _logger.exception('mark_chapter_complete failed') + return _error_response(str(e), 500) + + def _check_course_completion(self, student_id, course_id): + """Check if all chapters are completed; if so, mark course as completed and enable post-test.""" + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', course_id) + ]) + if not chapters: + return + completed = request.env['encoach.chapter.progress'].sudo().search_count([ + ('student_id', '=', student_id), + ('chapter_id', 'in', chapters.ids), + ('status', '=', 'completed'), + ]) + total = len(chapters) + progress_pct = int((completed / total * 100) if total else 0) + Completion = request.env['encoach.course.completion'].sudo() + comp = Completion.search([ + ('student_id', '=', student_id), + ('course_id', '=', course_id), + ], limit=1) + vals = { + 'chapters_total': total, + 'chapters_completed': completed, + 'progress_percent': progress_pct, + } + if completed >= total: + vals['status'] = 'completed' + vals['completed_at'] = fields.Datetime.now() + vals['post_test_available'] = True + else: + vals['status'] = 'in_progress' + vals['post_test_available'] = False + if comp: + comp.write(vals) + else: + vals['student_id'] = student_id + vals['course_id'] = course_id + Completion.create(vals) + + @http.route('/api/courses//completion', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_course_completion(self, course_id, **kw): + """Get course completion status for the current student.""" + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _json_response({ + 'course_id': course_id, + 'status': 'not_enrolled', + 'chapters_total': 0, + 'chapters_completed': 0, + 'progress_percent': 0, + 'post_test_available': False, + }) + comp = request.env['encoach.course.completion'].sudo().search([ + ('student_id', '=', student.id), + ('course_id', '=', course_id), + ], limit=1) + if comp: + return _json_response({ + 'course_id': course_id, + 'status': comp.status, + 'chapters_total': comp.chapters_total, + 'chapters_completed': comp.chapters_completed, + 'progress_percent': comp.progress_percent, + 'completed_at': str(comp.completed_at) if comp.completed_at else None, + 'post_test_available': comp.post_test_available, + }) + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', course_id) + ]) + completed = request.env['encoach.chapter.progress'].sudo().search_count([ + ('student_id', '=', student.id), + ('chapter_id', 'in', chapters.ids), + ('status', '=', 'completed'), + ]) + total = len(chapters) + return _json_response({ + 'course_id': course_id, + 'status': 'in_progress' if completed > 0 else 'not_started', + 'chapters_total': total, + 'chapters_completed': completed, + 'progress_percent': int((completed / total * 100) if total else 0), + 'post_test_available': completed >= total and total > 0, + }) + except Exception as e: + _logger.exception('get_course_completion failed') + return _error_response(str(e), 500) + + # ── Materials ──────────────────────────────────────────────────── + + @http.route('/api/chapters//materials', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_materials(self, cid, **kw): + try: + M = request.env['encoach.chapter.material'].sudo() + recs = M.search([('chapter_id', '=', cid)], order='sequence, id') + return _json_response([_ser_material(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//materials', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def upload_material(self, cid, **kw): + try: + files = request.httprequest.files + f = files.get('file') + body = {} + ct = request.httprequest.content_type or '' + if 'application/json' in ct: + body = _get_json_body() + params = {**body, **kw} + vals = { + 'chapter_id': cid, + 'name': params.get('name', f.filename if f else 'Material'), + 'type': params.get('type', 'pdf'), + 'sequence': int(params.get('sequence', 10)), + 'allow_download': str(params.get('allow_download', 'true')).lower() == 'true', + } + if f: + vals['file_data'] = base64.b64encode(f.read()) + vals['file_name'] = f.filename + if params.get('url'): + vals['url'] = params['url'] + if params.get('description'): + vals['description'] = params['description'] + rec = request.env['encoach.chapter.material'].sudo().create(vals) + return _json_response(_ser_material(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/chapters//materials/from-resource', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_material_from_resource(self, cid, **kw): + """Create a chapter material by copying from an existing library resource.""" + try: + body = _get_json_body() + resource_id = body.get('resource_id') + if not resource_id: + return _error_response('resource_id is required', 400) + res = request.env['encoach.resource'].sudo().browse(int(resource_id)) + if not res.exists(): + return _error_response('Resource not found', 404) + type_map = { + 'pdf': 'pdf', 'document': 'document', 'video': 'video', + 'link': 'link', 'interactive': 'document', + } + vals = { + 'chapter_id': cid, + 'name': body.get('name') or res.name, + 'type': type_map.get(res.type, 'document'), + 'sequence': int(body.get('sequence', 10)), + 'allow_download': True, + 'url': res.url or '', + 'resource_id': res.id, + } + if res.file: + vals['file_data'] = res.file + vals['file_name'] = res.name + rec = request.env['encoach.chapter.material'].sudo().create(vals) + return _json_response(_ser_material(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_material(self, mid, **kw): + try: + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'type', 'url', 'description'): + if k in body: + vals[k] = body[k] + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'allow_download' in body: + vals['allow_download'] = bool(body['allow_download']) + if vals: + rec.write(vals) + return _json_response(_ser_material(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_material(self, mid, **kw): + try: + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials//download', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def download_material(self, mid, **kw): + try: + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if not rec.exists() or not rec.file_data: + return _error_response('Not found', 404) + data = base64.b64decode(rec.file_data) + fname = rec.file_name or 'download' + return request.make_response(data, headers=[ + ('Content-Type', 'application/octet-stream'), + ('Content-Disposition', f'attachment; filename="{fname}"'), + ]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials//content', type='http', auth='public', methods=['GET'], csrf=False) + def material_content(self, mid, **kw): + """Serve material file inline (for iframe/embed) instead of as download. + Accepts JWT via Authorization header OR ?token= query param (for iframe src).""" + try: + from odoo.addons.encoach_api.controllers.base import validate_token, _get_jwt_secret + import jwt as pyjwt + user = validate_token() + if not user: + token_param = kw.get('token') or request.httprequest.args.get('token') + if token_param: + secret = _get_jwt_secret() + if secret: + try: + payload = pyjwt.decode(token_param, secret, algorithms=["HS256"]) + uid = payload.get("uid") + if uid: + user = request.env['res.users'].sudo().browse(int(uid)) + if user.exists(): + request.update_env(user=user.id) + except Exception: + pass + if not user: + return _error_response("Authentication required", 401) + rec = request.env['encoach.chapter.material'].sudo().browse(mid) + if not rec.exists() or not rec.file_data: + return _error_response('Not found', 404) + data = base64.b64decode(rec.file_data) + fname = rec.file_name or 'file' + ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else '' + mime_map = { + 'pdf': 'application/pdf', + 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogg': 'video/ogg', + 'mp3': 'audio/mpeg', 'wav': 'audio/wav', + 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', + 'gif': 'image/gif', 'svg': 'image/svg+xml', 'webp': 'image/webp', + 'html': 'text/html', 'htm': 'text/html', + 'txt': 'text/plain', + } + content_type = mime_map.get(ext, 'application/octet-stream') + return request.make_response(data, headers=[ + ('Content-Type', content_type), + ('Content-Disposition', f'inline; filename="{fname}"'), + ('Cache-Control', 'public, max-age=3600'), + ]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/materials//viewed', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_viewed(self, mid, **kw): + """Mark a material as viewed and update chapter progress.""" + try: + material = request.env['encoach.chapter.material'].sudo().browse(mid) + if not material.exists(): + return _error_response('Not found', 404) + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _json_response({'success': True}) + chapter = material.chapter_id + Progress = request.env['encoach.chapter.progress'].sudo() + prog = Progress.search([ + ('chapter_id', '=', chapter.id), + ('student_id', '=', student.id), + ], limit=1) + now = fields.Datetime.now() + if prog: + new_count = min(prog.materials_completed + 1, chapter.material_count) + vals = { + 'materials_completed': new_count, + 'materials_total': chapter.material_count, + } + if prog.status == 'not_started': + vals['status'] = 'in_progress' + vals['started_at'] = now + if new_count >= chapter.material_count: + vals['status'] = 'completed' + vals['completed_at'] = now + prog.write(vals) + else: + status = 'completed' if chapter.material_count <= 1 else 'in_progress' + prog = Progress.create({ + 'chapter_id': chapter.id, + 'student_id': student.id, + 'status': status, + 'started_at': now, + 'completed_at': now if status == 'completed' else False, + 'materials_completed': 1, + 'materials_total': chapter.material_count, + }) + if prog.status == 'completed': + self._check_course_completion(student.id, chapter.course_id.id) + return _json_response({'success': True, 'materials_completed': prog.materials_completed}) + except Exception as e: + _logger.exception('mark_viewed failed') + return _error_response(str(e), 500) + + @http.route('/api/chapters//materials/reorder', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def reorder_materials(self, cid, **kw): + try: + body = _get_json_body() + ids = body.get('ids', []) + for seq, mid in enumerate(ids): + rec = request.env['encoach.chapter.material'].sudo().browse(int(mid)) + if rec.exists(): + rec.write({'sequence': seq * 10}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/facilities.py b/custom_addons/encoach_lms_api/controllers/facilities.py new file mode 100644 index 0000000..cfb647e --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/facilities.py @@ -0,0 +1,161 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class FacilitiesController(http.Controller): + + @http.route('/api/facilities', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_facilities(self, **kw): + try: + M = request.env['op.facility'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'code': getattr(r, 'code', '') or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/facilities', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_facility(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + rec = request.env['op.facility'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name, 'code': getattr(rec, 'code', '')}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/facilities/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_facility(self, fid, **kw): + try: + rec = request.env['op.facility'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'code' in body: + vals['code'] = body['code'] + if vals: + rec.write(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/facilities/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_facility(self, fid, **kw): + try: + rec = request.env['op.facility'].sudo().browse(fid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Assets ─────────────────────────────────────────────────────── + + # Assets are tracked via encoach.asset (lightweight custom model since + # OpenEduCat has no dedicated asset model in this build). + + def _asset_model(self): + return request.env['encoach.asset'].sudo() + + @http.route('/api/assets', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_assets(self, **kw): + try: + M = self._asset_model() + offset, limit, page = _paginate(kw) + domain = [] + if kw.get('facility_id'): + try: + domain.append(('facility_id', '=', int(kw['facility_id']))) + except Exception: + pass + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'code': r.code or '', + 'facility_id': r.facility_id.id if r.facility_id else 0, + 'facility_name': r.facility_id.name if r.facility_id else '', + 'description': r.description or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_assets') + return _error_response(str(e), 500) + + @http.route('/api/assets', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_asset(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('Name is required', 400) + vals = {'name': name} + if body.get('code'): + vals['code'] = body['code'] + if body.get('facility_id'): + vals['facility_id'] = int(body['facility_id']) + if body.get('description'): + vals['description'] = body['description'] + rec = self._asset_model().create(vals) + return _json_response({ + 'id': rec.id, 'name': rec.name, 'code': rec.code or '', + 'facility_id': rec.facility_id.id if rec.facility_id else 0, + }) + except Exception as e: + _logger.exception('create_asset') + return _error_response(str(e), 500) + + @http.route('/api/assets/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_asset(self, aid, **kw): + try: + rec = self._asset_model().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'description'): + if k in body: + vals[k] = body[k] + if 'facility_id' in body: + vals['facility_id'] = int(body['facility_id']) if body['facility_id'] else False + if vals: + rec.write(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/assets/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_asset(self, aid, **kw): + try: + rec = self._asset_model().browse(aid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/faq.py b/custom_addons/encoach_lms_api/controllers/faq.py new file mode 100644 index 0000000..a3c4768 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/faq.py @@ -0,0 +1,169 @@ +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 _ser_cat(c): + return { + 'id': c.id, + 'name': c.name or '', + 'sequence': c.sequence, + 'audience': c.audience or 'all', + 'is_published': c.is_published, + 'item_count': c.item_count or 0, + } + + +def _ser_item(i): + return { + 'id': i.id, + 'category_id': i.category_id.id, + 'category_name': i.category_id.name or '', + 'question': i.question or '', + 'answer': i.answer or '', + 'sequence': i.sequence, + 'audience': i.audience or 'all', + 'is_published': i.is_published, + } + + +class FaqController(http.Controller): + + @http.route('/api/faq/categories', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_categories(self, **kw): + try: + M = request.env['encoach.faq.category'].sudo() + domain = [] + if kw.get('audience'): + domain.append(('audience', 'in', [kw['audience'], 'all'])) + recs = M.search(domain, order='sequence, id') + return _json_response([_ser_cat(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/categories', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_category(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('sequence') is not None: + vals['sequence'] = int(body['sequence']) + if body.get('audience'): + vals['audience'] = body['audience'] + rec = request.env['encoach.faq.category'].sudo().create(vals) + return _json_response(_ser_cat(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/categories/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_category(self, cid, **kw): + try: + rec = request.env['encoach.faq.category'].sudo().browse(cid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'audience'): + if k in body: + vals[k] = body[k] + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'is_published' in body: + vals['is_published'] = bool(body['is_published']) + if vals: + rec.write(vals) + return _json_response(_ser_cat(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/categories/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_category(self, cid, **kw): + try: + rec = request.env['encoach.faq.category'].sudo().browse(cid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Items ──────────────────────────────────────────────────────── + + @http.route('/api/faq/items', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_items(self, **kw): + try: + M = request.env['encoach.faq.item'].sudo() + domain = [] + if kw.get('category_id'): + domain.append(('category_id', '=', int(kw['category_id']))) + if kw.get('audience'): + domain.append(('audience', 'in', [kw['audience'], 'all'])) + if kw.get('search'): + domain.append(('question', 'ilike', kw['search'])) + recs = M.search(domain, order='sequence, id') + return _json_response([_ser_item(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/items', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_item(self, **kw): + try: + body = _get_json_body() + vals = { + 'question': body.get('question', ''), + 'answer': body.get('answer', ''), + 'category_id': int(body.get('category_id', 0)), + } + if body.get('sequence') is not None: + vals['sequence'] = int(body['sequence']) + if body.get('audience'): + vals['audience'] = body['audience'] + rec = request.env['encoach.faq.item'].sudo().create(vals) + return _json_response(_ser_item(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/items/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_item(self, iid, **kw): + try: + rec = request.env['encoach.faq.item'].sudo().browse(iid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('question', 'answer', 'audience'): + if k in body: + vals[k] = body[k] + if 'category_id' in body: + vals['category_id'] = int(body['category_id']) + if 'sequence' in body: + vals['sequence'] = int(body['sequence']) + if 'is_published' in body: + vals['is_published'] = bool(body['is_published']) + if vals: + rec.write(vals) + return _json_response(_ser_item(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/faq/items/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_item(self, iid, **kw): + try: + rec = request.env['encoach.faq.item'].sudo().browse(iid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/fees.py b/custom_addons/encoach_lms_api/controllers/fees.py new file mode 100644 index 0000000..baf6e34 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/fees.py @@ -0,0 +1,260 @@ +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, _paginate, _get_json_body, +) + +_get_json = _get_json_body + +_logger = logging.getLogger(__name__) + + +def _serialize_plan(r): + """Build a fees-plan record with REAL paid/remaining pulled from the + linked account.move (invoice), not a hard-coded zero.""" + total = float(getattr(r, 'amount', 0) or 0) + after_discount = float(getattr(r, 'after_discount_amount', 0) or total) + invoice = r.invoice_id if hasattr(r, 'invoice_id') else False + + invoice_state = '' + payment_state = 'not_paid' + amount_residual = after_discount + paid_amount = 0.0 + invoice_id = 0 + invoice_number = '' + + if invoice and invoice.exists(): + invoice_id = invoice.id + invoice_state = invoice.state or '' + payment_state = getattr(invoice, 'payment_state', '') or 'not_paid' + amount_residual = float(getattr(invoice, 'amount_residual', after_discount) or 0) + amount_total = float(getattr(invoice, 'amount_total', after_discount) or 0) + paid_amount = max(0.0, amount_total - amount_residual) + invoice_number = getattr(invoice, 'name', '') or '' + + return { + 'id': r.id, + 'student_id': r.student_id.id if getattr(r, 'student_id', False) else 0, + 'student_name': ( + r.student_id.partner_id.name + if getattr(r, 'student_id', False) and r.student_id.partner_id else '' + ), + 'course_id': r.course_id.id if getattr(r, 'course_id', False) else 0, + 'course_name': r.course_id.name if getattr(r, 'course_id', False) else '', + 'batch_id': r.batch_id.id if getattr(r, 'batch_id', False) else 0, + 'batch_name': r.batch_id.name if getattr(r, 'batch_id', False) else '', + 'product_id': r.product_id.id if getattr(r, 'product_id', False) else 0, + 'product_name': r.product_id.name if getattr(r, 'product_id', False) else '', + 'date': str(r.date) if getattr(r, 'date', False) else '', + 'discount': float(getattr(r, 'discount', 0) or 0), + 'total_amount': total, + 'after_discount_amount': after_discount, + 'paid_amount': round(paid_amount, 2), + 'remaining_amount': round(amount_residual, 2), + 'state': getattr(r, 'state', 'draft') or 'draft', + 'invoice_id': invoice_id, + 'invoice_number': invoice_number, + 'invoice_state': invoice_state, + 'payment_state': payment_state, + 'currency': r.currency_id.name if getattr(r, 'currency_id', False) else '', + } + + +class FeesController(http.Controller): + + @http.route('/api/fees-plans', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_fees_plans(self, **kw): + try: + M = request.env['op.student.fees.details'].sudo() + offset, limit, page = _paginate(kw) + domain = [] + state = (kw.get('state') or '').strip() + if state: + domain.append(('state', '=', state)) + payment_state = (kw.get('payment_state') or '').strip() + q = (kw.get('q') or '').strip() + if q: + domain += ['|', + ('student_id.partner_id.name', 'ilike', q), + ('product_id.name', 'ilike', q)] + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_serialize_plan(r) for r in recs] + if payment_state: + items = [i for i in items if i['payment_state'] == payment_state] + return _json_response({ + 'items': items, 'data': items, + 'total': total, 'page': page, 'size': limit, + }) + except Exception as e: + _logger.exception('list_fees_plans') + return _error_response(str(e), 500) + + @http.route('/api/fees-plans/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_fees_plan(self, fid, **kw): + try: + rec = request.env['op.student.fees.details'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_serialize_plan(rec)) + except Exception as e: + _logger.exception('get_fees_plan') + return _error_response(str(e), 500) + + @http.route('/api/fees-plans//create-invoice', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_invoice(self, fid, **kw): + """Generate the accounting invoice for this fee line (uses the built-in + OpenEduCat `get_invoice()` model method).""" + try: + rec = request.env['op.student.fees.details'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Fees plan not found', 404) + if not rec.product_id: + return _error_response('This fees plan has no product; cannot invoice.', 400) + if float(rec.amount or 0) <= 0: + return _error_response('Fees amount must be positive to invoice.', 400) + + # Auto-wire a default income account on the product/category if + # the Odoo chart of accounts is set up but the product was seeded + # without one. Otherwise OpenEduCat raises a UserError. + prod_tmpl = rec.product_id.sudo().product_tmpl_id + try: + fp = prod_tmpl.get_product_accounts() if hasattr( + prod_tmpl, 'get_product_accounts') else {} + except Exception: + fp = {} + if not (fp or {}).get('income'): + AA = request.env['account.account'].sudo() + # Odoo 19: account.account has a M2M `company_ids`. + try: + income_acc = AA.search([ + ('account_type', '=', 'income'), + ('company_ids', 'in', request.env.company.id), + ], limit=1) + except Exception: + income_acc = AA.browse() + if not income_acc: + income_acc = AA.search([('account_type', '=', 'income')], limit=1) + if income_acc: + try: + prod_tmpl.with_company(request.env.company).write({ + 'property_account_income_id': income_acc.id, + }) + except Exception: + _logger.exception('auto-wire income account') + rec.get_invoice() + rec.invalidate_recordset() + return _json_response(_serialize_plan(rec)) + except Exception as e: + _logger.exception('create_invoice') + return _error_response(str(e), 500) + + @http.route('/api/fees-plans//register-payment', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def register_payment(self, fid, **kw): + """Register a payment against the invoice linked to this fees plan. + Body: { "amount": float, "journal_id": int (optional), + "payment_date": "YYYY-MM-DD" (optional), "memo": str (optional) }""" + try: + body = _get_json() or {} + rec = request.env['op.student.fees.details'].sudo().browse(fid) + if not rec.exists(): + return _error_response('Fees plan not found', 404) + invoice = rec.invoice_id + if not invoice or not invoice.exists(): + return _error_response('Create an invoice before registering a payment.', 400) + if invoice.state == 'draft': + invoice.sudo().action_post() + + amount = float(body.get('amount') or invoice.amount_residual or 0) + if amount <= 0: + return _error_response('Payment amount must be positive.', 400) + + payment_date = body.get('payment_date') or False + memo = body.get('memo') or f"Payment for {rec.product_id.name or 'fees'}" + + journal_id = body.get('journal_id') + if not journal_id: + journal = request.env['account.journal'].sudo().search( + [('type', 'in', ('bank', 'cash'))], limit=1 + ) + if not journal: + return _error_response( + 'No bank/cash journal configured. Open Accounting → Configuration → Journals.', + 400) + journal_id = journal.id + + wiz_vals = { + 'amount': amount, + 'journal_id': int(journal_id), + 'communication': memo, + } + if payment_date: + wiz_vals['payment_date'] = payment_date + wizard = request.env['account.payment.register'].sudo().with_context( + active_model='account.move', active_ids=[invoice.id] + ).create(wiz_vals) + wizard.action_create_payments() + invoice.invalidate_recordset() + rec.invalidate_recordset() + return _json_response(_serialize_plan(rec)) + except Exception as e: + _logger.exception('register_payment') + return _error_response(str(e), 500) + + @http.route('/api/student-fees', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_student_fees(self, **kw): + try: + M = request.env['op.student.fees.details'].sudo() + offset, limit, page = _paginate(kw) + domain = [] + student_id = kw.get('student_id') + if student_id: + try: + domain.append(('student_id', '=', int(student_id))) + except Exception: + pass + q = (kw.get('q') or '').strip() + if q: + domain += ['|', + ('student_id.partner_id.name', 'ilike', q), + ('product_id.name', 'ilike', q)] + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_serialize_plan(r) for r in recs] + return _json_response({ + 'items': items, 'data': items, + 'total': total, 'page': page, 'size': limit, + }) + except Exception as e: + _logger.exception('list_student_fees') + return _error_response(str(e), 500) + + @http.route('/api/fees-terms', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_fees_terms(self, **kw): + try: + M = request.env['op.fees.terms'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'no_days': getattr(r, 'no_days', 0) or 0, + 'line_ids': r.line_ids.ids if hasattr(r, 'line_ids') else [], + } for r in recs] + return _json_response({ + 'items': items, 'data': items, + 'total': total, 'page': page, 'size': limit, + }) + except Exception as e: + _logger.exception('list_fees_terms') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/grades.py b/custom_addons/encoach_lms_api/controllers/grades.py new file mode 100644 index 0000000..eb89027 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/grades.py @@ -0,0 +1,201 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class GradesController(http.Controller): + + # ── Grades (simple list) ───────────────────────────────────────── + + @http.route('/api/grades', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_grades(self, **kw): + try: + M = request.env['encoach.gradebook.line'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + data = [] + for r in recs: + gb = r.gradebook_id + data.append({ + 'id': r.id, + 'course_id': gb.course_id.id if gb.course_id else 0, + 'course_name': gb.course_id.name if gb.course_id else '', + 'student_id': gb.student_id.id if gb.student_id else 0, + 'student_name': gb.student_id.partner_id.name if gb.student_id and gb.student_id.partner_id else '', + 'assignment_title': r.assignment_name or '', + 'grade': r.marks, + 'max_grade': 100, + 'date': str(r.create_date.date()) if r.create_date else '', + 'type': r.state or 'graded', + }) + return _json_response({'data': data}) + except Exception as e: + _logger.exception('list_grades') + return _error_response(str(e), 500) + + # ── Gradebooks ─────────────────────────────────────────────────── + + @http.route('/api/gradebooks', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_gradebooks(self, **kw): + try: + M = request.env['encoach.gradebook'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if r.student_id else 0, + 'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '', + 'course_id': r.course_id.id if r.course_id else 0, + 'course_name': r.course_id.name if r.course_id else '', + 'academic_year_id': r.academic_year_id.id if r.academic_year_id else 0, + 'academic_year_name': r.academic_year_id.name if r.academic_year_id else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Gradebook Lines ────────────────────────────────────────────── + + @http.route('/api/gradebook-lines', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_gradebook_lines(self, **kw): + try: + M = request.env['encoach.gradebook.line'].sudo() + offset, limit, page = _paginate(kw) + domain = [] + gb_id = kw.get('gradebook_id') + if gb_id: + try: + domain.append(('gradebook_id', '=', int(gb_id))) + except Exception: + pass + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'gradebook_id': r.gradebook_id.id, + 'student_name': r.gradebook_id.student_id.partner_id.name if r.gradebook_id.student_id and r.gradebook_id.student_id.partner_id else '', + 'assignment_name': r.assignment_name or '', + 'marks': r.marks, + 'percentage': r.percentage, + 'state': r.state or 'draft', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Grading Assignments (using openeducat grading.assignment) ──── + + @http.route('/api/grading-assignments', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_grading_assignments(self, **kw): + try: + M = request.env['grading.assignment'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'sequence': getattr(r, 'sequence', '') or '', + 'name': r.name or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'subject_id': r.subject_id.id if hasattr(r, 'subject_id') and r.subject_id else 0, + 'subject_name': r.subject_id.name if hasattr(r, 'subject_id') and r.subject_id else '', + 'state': getattr(r, 'state', 'draft') or 'draft', + 'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/grading-assignments', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_grading_assignment(self, **kw): + try: + from odoo import fields as odoo_fields + body = _get_json_body() + + # assignment_type is required on grading.assignment. Find or create a default. + AType = request.env['grading.assignment.type'].sudo() + atype_id = body.get('assignment_type') or body.get('assignment_type_id') + if atype_id: + atype_id = int(atype_id) + else: + atype = AType.search([('code', '=', 'DEFAULT')], limit=1) + if not atype: + atype = AType.create({'name': 'Default', 'code': 'DEFAULT'}) + atype_id = atype.id + + # faculty_id is required. Prefer body, otherwise first available. + Faculty = request.env['op.faculty'].sudo() + faculty_id = body.get('faculty_id') + if faculty_id: + faculty_id = int(faculty_id) + else: + fac = Faculty.search([], limit=1) + if fac: + faculty_id = fac.id + + if not faculty_id: + return _error_response('A faculty is required but none exist in the system.', 400) + + vals = { + 'name': body.get('name') or 'Assignment', + 'issued_date': body.get('issued_date') or odoo_fields.Datetime.now(), + 'assignment_type': atype_id, + 'faculty_id': faculty_id, + } + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('subject_id'): + vals['subject_id'] = int(body['subject_id']) + if body.get('point') is not None: + vals['point'] = float(body['point']) + rec = request.env['grading.assignment'].sudo().create(vals) + return _json_response({'data': {'id': rec.id, 'name': rec.name}}) + except Exception as e: + _logger.exception('create_grading_assignment') + return _error_response(str(e), 500) + + @http.route('/api/grading-assignments/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_grading_assignment(self, gid, **kw): + try: + rec = request.env['grading.assignment'].sudo().browse(gid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'issued_date'): + if k in body: + vals[k] = body[k] + if 'course_id' in body: + vals['course_id'] = int(body['course_id']) + if 'subject_id' in body: + vals['subject_id'] = int(body['subject_id']) + if vals: + rec.write(vals) + return _json_response({'data': {'id': rec.id, 'name': rec.name}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/grading-assignments/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_grading_assignment(self, gid, **kw): + try: + rec = request.env['grading.assignment'].sudo().browse(gid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/institutional_exams.py b/custom_addons/encoach_lms_api/controllers/institutional_exams.py new file mode 100644 index 0000000..1d49950 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/institutional_exams.py @@ -0,0 +1,753 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_exam(e): + return { + 'id': e.id, + 'name': e.name or '', + 'session_id': e.session_id.id if hasattr(e, 'session_id') and e.session_id else 0, + 'subject_id': e.subject_id.id if hasattr(e, 'subject_id') and e.subject_id else 0, + 'subject_name': e.subject_id.name if hasattr(e, 'subject_id') and e.subject_id else '', + 'exam_code': getattr(e, 'exam_code', '') or '', + 'start_time': str(e.start_time) if hasattr(e, 'start_time') and e.start_time else '', + 'end_time': str(e.end_time) if hasattr(e, 'end_time') and e.end_time else '', + 'total_marks': getattr(e, 'total_marks', 0) or 0, + 'min_marks': getattr(e, 'min_marks', 0) or 0, + 'state': getattr(e, 'state', 'draft') or 'draft', + 'responsible_names': [p.name for p in (getattr(e, 'responsible_ids', False) or [])], + 'attendee_count': len(getattr(e, 'attendees_line', False) or []), + } + + +def _ser_session(r, *, with_exams=False): + exams = [] + if with_exams and hasattr(r, 'exam_ids'): + exams = [_ser_exam(e) for e in r.exam_ids] + exam_count = len(getattr(r, 'exam_ids', False) or []) + exam_type = getattr(r, 'exam_type', False) + return { + 'id': r.id, + 'name': r.name or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'batch_id': r.batch_id.id if hasattr(r, 'batch_id') and r.batch_id else 0, + 'batch_name': r.batch_id.name if hasattr(r, 'batch_id') and r.batch_id else '', + 'exam_code': getattr(r, 'exam_code', '') or '', + 'start_date': str(r.start_date) if hasattr(r, 'start_date') and r.start_date else '', + 'end_date': str(r.end_date) if hasattr(r, 'end_date') and r.end_date else '', + 'exam_type_id': exam_type.id if exam_type else 0, + 'exam_type_name': exam_type.name if exam_type else '', + 'evaluation_type': getattr(r, 'evaluation_type', 'normal') or 'normal', + 'venue': getattr(r, 'venue', '') or '', + 'state': getattr(r, 'state', 'draft') or 'draft', + 'exam_count': exam_count, + 'exams': exams, + } + + +def _ser_template(r): + exam_session = getattr(r, 'exam_session_id', False) + grade_ids = [] + if hasattr(r, 'grade_ids'): + grade_ids = r.grade_ids.ids + return { + 'id': r.id, + 'name': r.name or '', + 'exam_session_id': exam_session.id if exam_session else 0, + 'exam_session_name': exam_session.name if exam_session else '', + 'evaluation_type': getattr(r, 'evaluation_type', 'normal') or 'normal', + 'result_date': str(r.result_date) if hasattr(r, 'result_date') and r.result_date else '', + 'grade_ids': grade_ids, + 'state': getattr(r, 'state', 'draft') or 'draft', + } + + +def _ser_marksheet_line(l): + return { + 'id': l.id, + 'student_id': l.student_id.id if hasattr(l, 'student_id') and l.student_id else 0, + 'student_name': l.student_id.name if hasattr(l, 'student_id') and l.student_id else '', + 'total_marks': getattr(l, 'total_marks', 0) or 0, + 'percentage': getattr(l, 'percentage', 0) or 0, + 'grade': getattr(l, 'grade', '') or '', + 'status': getattr(l, 'status', 'fail') or 'fail', + 'results': [], + } + + +def _ser_marksheet(r, *, with_lines=False): + exam_session = getattr(r, 'exam_session_id', False) + line_recs = getattr(r, 'marksheet_line', False) or [] + lines = [_ser_marksheet_line(l) for l in line_recs] if with_lines else [] + return { + 'id': r.id, + 'name': r.name or '', + 'exam_session_id': exam_session.id if exam_session else 0, + 'exam_session_name': exam_session.name if exam_session else '', + 'result_template_id': r.result_template_id.id if hasattr(r, 'result_template_id') and r.result_template_id else 0, + 'generated_date': str(r.generated_date) if hasattr(r, 'generated_date') and r.generated_date else '', + 'generated_by_name': r.generated_by.name if hasattr(r, 'generated_by') and r.generated_by else '', + 'state': getattr(r, 'state', 'draft') or 'draft', + 'total_pass': getattr(r, 'total_pass', 0) or 0, + 'total_failed': getattr(r, 'total_failed', 0) or 0, + 'lines': lines, + } + + +class InstitutionalExamsController(http.Controller): + + # ── Exam Sessions (op.exam.session) ────────────────────────────── + + @http.route('/api/inst-exam-sessions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_sessions(self, **kw): + try: + M = request.env['op.exam.session'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + q = (kw.get('q') or '').strip() + if q: + domain.append(('name', 'ilike', q)) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_session(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_sessions') + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_session(self, sid, **kw): + try: + r = request.env['op.exam.session'].sudo().browse(sid) + if not r.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_session(r, with_exams=True)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_session(self, **kw): + try: + body = _get_json_body() + Session = request.env['op.exam.session'].sudo() + vals = {'name': body.get('name', '')} + for fk in ('course_id', 'batch_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + if body.get('exam_type_id') and 'exam_type' in Session._fields: + vals['exam_type'] = int(body['exam_type_id']) + if body.get('evaluation_type') and 'evaluation_type' in Session._fields: + vals['evaluation_type'] = body['evaluation_type'] + if body.get('venue') and 'venue' in Session._fields: + vals['venue'] = body['venue'] + if body.get('exam_code') and 'exam_code' in Session._fields: + vals['exam_code'] = body['exam_code'] + for dt in ('start_date', 'end_date'): + if body.get(dt): + vals[dt] = body[dt] + rec = Session.create(vals) + return _json_response(_ser_session(rec, with_exams=True)) + except Exception as e: + _logger.exception('create_session') + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'start_date', 'end_date', 'venue', 'exam_code', 'evaluation_type'): + if k in body and k in rec._fields: + vals[k] = body[k] + for fk in ('course_id', 'batch_id'): + if fk in body and fk in rec._fields: + vals[fk] = int(body[fk]) if body[fk] else False + if 'exam_type_id' in body and 'exam_type' in rec._fields: + vals['exam_type'] = int(body['exam_type_id']) if body['exam_type_id'] else False + if vals: + rec.write(vals) + return _json_response(_ser_session(rec, with_exams=True)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions//schedule', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def schedule_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'schedule'}) + return _json_response({'id': rec.id, 'state': 'schedule'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exam-sessions//done', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def done_session(self, sid, **kw): + try: + rec = request.env['op.exam.session'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'done'}) + return _json_response({'id': rec.id, 'state': 'done'}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Exams (op.exam) ────────────────────────────────────────────── + + @http.route('/api/inst-exams', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_exam(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + for fk in ('session_id', 'subject_id', 'course_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + if body.get('total_marks'): + vals['total_marks'] = float(body['total_marks']) + rec = request.env['op.exam'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exams/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_exam(self, eid, **kw): + try: + rec = request.env['op.exam'].sudo().browse(eid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'total_marks'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exams//attendees', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def add_attendees(self, eid, **kw): + try: + body = _get_json_body() + student_ids = body.get('student_ids', []) + Att = request.env['op.exam.attendees'].sudo() + for sid in student_ids: + Att.create({'exam_id': eid, 'student_id': int(sid)}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/inst-exams//marks', type='http', auth='public', methods=['PATCH'], csrf=False) + @jwt_required + def enter_marks(self, eid, **kw): + try: + body = _get_json_body() + marks = body.get('marks', []) + Att = request.env['op.exam.attendees'].sudo() + for m in marks: + att = Att.search([('exam_id', '=', eid), ('student_id', '=', int(m['student_id']))], limit=1) + if att: + att.write({'marks': float(m.get('score', 0))}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Exam Types ─────────────────────────────────────────────────── + + @http.route('/api/exam-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_exam_types(self, **kw): + try: + M = request.env['op.exam.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{'id': r.id, 'name': r.name or ''} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/exam-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_exam_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['op.exam.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Grade Configurations ───────────────────────────────────────── + + @http.route('/api/grade-config', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_grade_config(self, **kw): + try: + M = request.env['op.grade.configuration'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{ + 'id': r.id, + 'name': r.name or '', + 'min_per': getattr(r, 'min_per', 0), + 'max_per': getattr(r, 'max_per', 0), + 'result': getattr(r, 'result', '') or '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/grade-config', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_grade_config(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + for k in ('min_per', 'max_per'): + if body.get(k) is not None: + vals[k] = float(body[k]) + if body.get('result'): + vals['result'] = body['result'] + rec = request.env['op.grade.configuration'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Result Templates ───────────────────────────────────────────── + + @http.route('/api/result-templates', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_result_templates(self, **kw): + try: + M = request.env['op.result.template'].sudo() + domain = [] + if kw.get('exam_session_id'): + domain.append(('exam_session_id', '=', int(kw['exam_session_id']))) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_template(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_result_templates') + return _error_response(str(e), 500) + + @http.route('/api/result-templates', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_result_template(self, **kw): + try: + body = _get_json_body() + Tpl = request.env['op.result.template'].sudo() + vals = {'name': body.get('name', '')} + if body.get('exam_session_id') and 'exam_session_id' in Tpl._fields: + vals['exam_session_id'] = int(body['exam_session_id']) + if body.get('evaluation_type') and 'evaluation_type' in Tpl._fields: + vals['evaluation_type'] = body['evaluation_type'] + if body.get('result_date') and 'result_date' in Tpl._fields: + vals['result_date'] = body['result_date'] + grade_ids = body.get('grade_ids') or [] + if grade_ids and 'grade_ids' in Tpl._fields: + vals['grade_ids'] = [(6, 0, [int(g) for g in grade_ids])] + rec = Tpl.create(vals) + return _json_response(_ser_template(rec)) + except Exception as e: + _logger.exception('create_result_template') + return _error_response(str(e), 500) + + @http.route(['/api/result-templates//generate', + '/api/result-templates//generate-marksheets'], + type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def generate_marksheets(self, tid, **kw): + try: + rec = request.env['op.result.template'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + # OpenEduCat 19 names the method `generate_result`; + # older versions used `action_generate_marksheet`. + if hasattr(rec, 'generate_result'): + rec.generate_result() + elif hasattr(rec, 'action_generate_marksheet'): + rec.action_generate_marksheet() + else: + return _error_response( + 'No marksheet-generation method on op.result.template ' + '(expected generate_result or action_generate_marksheet).', + 400, + ) + return _json_response(_ser_template(rec)) + except Exception as e: + _logger.exception('generate_marksheets') + return _error_response(str(e), 500) + + @http.route('/api/result-templates/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_result_template(self, tid, **kw): + try: + rec = request.env['op.result.template'].sudo().browse(tid) + if rec.exists(): + # If this template already generated marksheet registers, + # unlink them first so the FK RESTRICT doesn't block us. + session = rec.exam_session_id + if session: + regs = request.env['op.marksheet.register'].sudo().search([ + ('result_template_id', '=', rec.id), + ]) + if regs: + # Unlink the lines + registers (CASCADE ok on lines). + regs.sudo().unlink() + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_result_template') + return _error_response(str(e), 500) + + @http.route('/api/result-templates/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_result_template(self, tid, **kw): + try: + rec = request.env['op.result.template'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'result_date', 'evaluation_type'): + if k in body and k in rec._fields: + vals[k] = body[k] + if 'grade_ids' in body and 'grade_ids' in rec._fields: + vals['grade_ids'] = [(6, 0, [int(g) for g in body['grade_ids']])] + if vals: + rec.write(vals) + return _json_response(_ser_template(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Marksheets ─────────────────────────────────────────────────── + + @http.route('/api/marksheets', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_marksheets(self, **kw): + try: + M = request.env['op.marksheet.register'].sudo() + domain = [] + if kw.get('exam_session_id'): + domain.append(('exam_session_id', '=', int(kw['exam_session_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_marksheet(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_marksheets') + return _error_response(str(e), 500) + + @http.route('/api/marksheets/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_marksheet(self, mid, **kw): + try: + rec = request.env['op.marksheet.register'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_marksheet(rec, with_lines=True)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/marksheets//validate', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def validate_marksheet(self, mid, **kw): + try: + rec = request.env['op.marksheet.register'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + if hasattr(rec, 'action_validate'): + rec.action_validate() + else: + rec.write({'state': 'validated'}) + return _json_response(_ser_marksheet(rec, with_lines=True)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Course Assignments (OpenEduCat op.assignment) ───────────────── + + @http.route('/api/course-assignments', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_course_assignments(self, **kw): + try: + M = request.env['op.assignment'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'course_name': r.course_id.name if hasattr(r, 'course_id') and r.course_id else '', + 'batch_id': r.batch_id.id if hasattr(r, 'batch_id') and r.batch_id else 0, + 'subject_id': r.subject_id.id if hasattr(r, 'subject_id') and r.subject_id else 0, + 'state': getattr(r, 'state', 'draft') or 'draft', + 'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_course_assignment(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + for fk in ('course_id', 'batch_id', 'subject_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + if body.get('issued_date'): + vals['issued_date'] = body['issued_date'] + rec = request.env['op.assignment'].sudo().create(vals) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_course_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'id': rec.id, 'name': rec.name or '', 'state': getattr(rec, 'state', 'draft')}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_course_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments//publish', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def publish_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'publish'}) + return _json_response({'id': rec.id, 'state': 'publish'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/course-assignments//finish', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def finish_assignment(self, aid, **kw): + try: + rec = request.env['op.assignment'].sudo().browse(aid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'finish'}) + return _json_response({'id': rec.id, 'state': 'finish'}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Assignment Types ───────────────────────────────────────────── + + @http.route('/api/assignment-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_assignment_types(self, **kw): + try: + M = request.env['grading.assignment.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{'id': r.id, 'name': r.name or ''} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/assignment-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_assignment_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['grading.assignment.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Subject Registrations (op.subject.registration) ────────────── + + @http.route('/api/subject-registrations', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_subject_registrations(self, **kw): + try: + M = request.env['op.subject.registration'].sudo() + domain = [] + if kw.get('student_id'): + domain.append(('student_id', '=', int(kw['student_id']))) + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('state'): + domain.append(('state', '=', kw['state'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'course_id': r.course_id.id if hasattr(r, 'course_id') and r.course_id else 0, + 'state': getattr(r, 'state', 'draft') or 'draft', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_subject_registration(self, **kw): + try: + body = _get_json_body() + vals = {} + for fk in ('student_id', 'course_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + rec = request.env['op.subject.registration'].sudo().create(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_subject_registration(self, rid, **kw): + try: + rec = request.env['op.subject.registration'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations//confirm', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def confirm_registration(self, rid, **kw): + try: + rec = request.env['op.subject.registration'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'confirm'}) + return _json_response({'id': rec.id, 'state': 'confirm'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations//reject', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def reject_registration(self, rid, **kw): + try: + rec = request.env['op.subject.registration'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'reject'}) + return _json_response({'id': rec.id, 'state': 'reject'}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subject-registrations/available', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def available_subjects(self, **kw): + try: + return _json_response([]) + except Exception as e: + return _error_response(str(e), 500) + + # ── Submissions ────────────────────────────────────────────────── + + @http.route('/api/course-assignments//submissions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_submissions(self, aid, **kw): + try: + M = request.env['op.assignment.sub.line'].sudo() + domain = [('assignment_id', '=', aid)] + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'state': getattr(r, 'state', 'draft') or 'draft', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/submissions/', type='http', auth='public', methods=['PATCH'], csrf=False) + @jwt_required + def grade_submission(self, sid, **kw): + try: + rec = request.env['op.assignment.sub.line'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'marks' in body: + vals['marks'] = float(body['marks']) + if vals: + rec.write(vals) + return _json_response({'id': rec.id}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/lessons.py b/custom_addons/encoach_lms_api/controllers/lessons.py new file mode 100644 index 0000000..4f7b01d --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/lessons.py @@ -0,0 +1,106 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_lesson(r): + return { + 'id': r.id, + 'name': r.name or '', + 'lesson_topic': r.lesson_topic or '', + 'subject_id': r.subject_id.id if r.subject_id else 0, + 'subject_name': r.subject_id.name if r.subject_id else '', + 'session_id': r.session_id.id if r.session_id else 0, + 'session_name': r.session_id.display_name if r.session_id else '', + 'course_id': r.course_id.id if r.course_id else 0, + 'batch_id': r.batch_id.id if r.batch_id else 0, + 'faculty_id': r.faculty_id.id if r.faculty_id else 0, + 'start_datetime': str(r.start_datetime) if r.start_datetime else '', + 'end_datetime': str(r.end_datetime) if r.end_datetime else '', + 'status': r.status or 'draft', + } + + +class LessonsController(http.Controller): + + @http.route('/api/lessons', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_lessons(self, **kw): + try: + M = request.env['encoach.lesson'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='start_datetime desc, id desc') + items = [_ser_lesson(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_lesson(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('name'): + vals['name'] = body['name'] + if body.get('lesson_topic'): + vals['lesson_topic'] = body['lesson_topic'] + for fk in ('course_id', 'batch_id', 'subject_id', 'session_id', 'faculty_id'): + if body.get(fk): + vals[fk] = int(body[fk]) + for dt in ('start_datetime', 'end_datetime'): + if body.get(dt): + vals[dt] = body[dt] + rec = request.env['encoach.lesson'].sudo().create(vals) + return _json_response(_ser_lesson(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_lesson(self, lid, **kw): + try: + rec = request.env['encoach.lesson'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_lesson(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_lesson(self, lid, **kw): + try: + rec = request.env['encoach.lesson'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'lesson_topic', 'start_datetime', 'end_datetime', 'status'): + if k in body: + vals[k] = body[k] + for fk in ('course_id', 'batch_id', 'subject_id', 'session_id', 'faculty_id'): + if fk in body: + vals[fk] = int(body[fk]) if body[fk] else False + if vals: + rec.write(vals) + return _json_response(_ser_lesson(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/lessons/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_lesson(self, lid, **kw): + try: + rec = request.env['encoach.lesson'].sudo().browse(lid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/library.py b/custom_addons/encoach_lms_api/controllers/library.py new file mode 100644 index 0000000..27cc981 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/library.py @@ -0,0 +1,215 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class LibraryController(http.Controller): + + # ── Media ──────────────────────────────────────────────────────── + + @http.route('/api/library/media', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_media(self, **kw): + try: + M = request.env['op.media'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'name': r.name or '', + 'isbn': getattr(r, 'isbn', '') or '', + 'author': ', '.join(a.name for a in r.author_ids) if hasattr(r, 'author_ids') else '', + 'edition': getattr(r, 'edition', '') or '', + 'media_type': r.media_type_id.name if hasattr(r, 'media_type_id') and r.media_type_id else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_media') + return _error_response(str(e), 500) + + @http.route('/api/library/media', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_media(self, **kw): + try: + body = _get_json_body() + name = (body.get('name') or '').strip() + if not name: + return _error_response('Name is required', 400) + Media = request.env['op.media'].sudo() + vals = {'name': name} + if body.get('isbn'): + vals['isbn'] = body['isbn'] + if body.get('edition'): + vals['edition'] = body['edition'] + + author_names = [] + raw_author = body.get('author') + if isinstance(raw_author, str) and raw_author.strip(): + author_names = [a.strip() for a in raw_author.split(',') if a.strip()] + raw_author_ids = body.get('author_ids') + author_ids = [] + if isinstance(raw_author_ids, (list, tuple)): + author_ids = [int(a) for a in raw_author_ids if a] + if author_names and 'author_ids' in Media._fields: + Author = request.env['op.author'].sudo() if 'op.author' in request.env else None + if Author is not None: + for nm in author_names: + existing = Author.search([('name', '=', nm)], limit=1) + if not existing: + existing = Author.create({'name': nm}) + author_ids.append(existing.id) + if author_ids and 'author_ids' in Media._fields: + vals['author_ids'] = [(6, 0, list(set(author_ids)))] + + rec = Media.create(vals) + return _json_response({'data': {'id': rec.id, 'name': rec.name}}) + except Exception as e: + _logger.exception('create_media') + return _error_response(str(e), 500) + + @http.route('/api/library/media/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_media(self, mid, **kw): + try: + rec = request.env['op.media'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + author_names = [a.name for a in rec.author_ids] if hasattr(rec, 'author_ids') else [] + return _json_response({ + 'id': rec.id, + 'name': rec.name or '', + 'isbn': getattr(rec, 'isbn', '') or '', + 'edition': getattr(rec, 'edition', '') or '', + 'author_ids': rec.author_ids.ids if hasattr(rec, 'author_ids') else [], + 'author_names': author_names, + 'author': ', '.join(author_names), + 'publisher_ids': rec.publisher_ids.ids if hasattr(rec, 'publisher_ids') else [], + 'media_type': rec.media_type_id.name if hasattr(rec, 'media_type_id') and rec.media_type_id else '', + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/media/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_media(self, mid, **kw): + try: + rec = request.env['op.media'].sudo().browse(mid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Movements ──────────────────────────────────────────────────── + + @http.route('/api/library/movements', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_movements(self, **kw): + try: + M = request.env['op.media.movement'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'media_id': r.media_id.id if r.media_id else 0, + 'media_name': r.media_id.name if r.media_id else '', + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'student_name': r.student_id.partner_id.name if hasattr(r, 'student_id') and r.student_id and r.student_id.partner_id else '', + 'faculty_id': r.faculty_id.id if hasattr(r, 'faculty_id') and r.faculty_id else 0, + 'faculty_name': r.faculty_id.partner_id.name if hasattr(r, 'faculty_id') and r.faculty_id and r.faculty_id.partner_id else '', + 'issued_date': str(r.issued_date) if hasattr(r, 'issued_date') and r.issued_date else '', + 'return_date': str(r.return_date) if hasattr(r, 'return_date') and r.return_date else '', + 'state': getattr(r, 'state', 'issue') or 'issue', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/movements', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_movement(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('media_id'): + vals['media_id'] = int(body['media_id']) + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + if body.get('faculty_id'): + vals['faculty_id'] = int(body['faculty_id']) + rec = request.env['op.media.movement'].sudo().create(vals) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/movements/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_movement(self, mid, **kw): + try: + rec = request.env['op.media.movement'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'return_date' in body: + vals['return_date'] = body['return_date'] + if vals: + rec.write(vals) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/movements//return', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def return_movement(self, mid, **kw): + try: + rec = request.env['op.media.movement'].sudo().browse(mid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import date + rec.write({'return_date': str(date.today()), 'state': 'return'}) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Library Cards ──────────────────────────────────────────────── + + @http.route('/api/library/cards', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_cards(self, **kw): + try: + M = request.env['op.library.card'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='id desc') + items = [{ + 'id': r.id, + 'number': r.number if hasattr(r, 'number') else str(r.id), + 'student_id': r.student_id.id if hasattr(r, 'student_id') and r.student_id else 0, + 'student_name': r.student_id.partner_id.name if hasattr(r, 'student_id') and r.student_id and r.student_id.partner_id else '', + 'faculty_id': r.faculty_id.id if hasattr(r, 'faculty_id') and r.faculty_id else 0, + 'faculty_name': r.faculty_id.partner_id.name if hasattr(r, 'faculty_id') and r.faculty_id and r.faculty_id.partner_id else '', + } for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/library/cards', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_card(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + rec = request.env['op.library.card'].sudo().create(vals) + return _json_response({'data': {'id': rec.id}}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/lms_core.py b/custom_addons/encoach_lms_api/controllers/lms_core.py new file mode 100644 index 0000000..dbee1de --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/lms_core.py @@ -0,0 +1,854 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _serialize_course(c): + subj = getattr(c, 'encoach_subject_id', False) + tags = getattr(c, 'encoach_tag_ids', c.env['encoach.resource.tag']) + topics = getattr(c, 'encoach_topic_ids', c.env['encoach.topic']) + objectives = getattr(c, 'learning_objective_ids', c.env['encoach.learning.objective']) + enrolled_count = c.env['op.student.course'].sudo().search_count([('course_id', '=', c.id)]) + return { + 'id': c.id, + 'name': c.name or '', + 'title': c.name or '', + 'code': c.code or '', + 'description': getattr(c, 'description', '') or '', + 'status': 'active' if c.id else 'draft', + 'student_count': enrolled_count, + 'max_capacity': getattr(c, 'max_capacity', 0) or 0, + 'subject_ids': c.subject_ids.ids if hasattr(c, 'subject_ids') else [], + 'subject_names': [s.name for s in c.subject_ids] if hasattr(c, 'subject_ids') else [], + 'department_id': c.department_id.id if hasattr(c, 'department_id') and c.department_id else None, + 'department_name': c.department_id.name if hasattr(c, 'department_id') and c.department_id else '', + 'encoach_subject_id': subj.id if subj else None, + 'encoach_subject_name': subj.name if subj else '', + 'topic_ids': topics.ids, + 'topic_names': topics.mapped('name'), + 'learning_objective_ids': objectives.ids, + 'learning_objective_names': objectives.mapped('name'), + 'tag_ids': tags.ids, + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags], + 'difficulty_level': getattr(c, 'difficulty_level', '') or '', + 'cefr_level': getattr(c, 'cefr_level', '') or '', + 'chapter_count': getattr(c, 'chapter_count', 0) or 0, + 'resource_count': getattr(c, 'resource_count', 0) or 0, + 'objective_count': getattr(c, 'objective_count', 0) or 0, + } + + +def _serialize_student(s): + partner = s.partner_id + first = getattr(s, 'first_name', '') or (partner.name.split()[0] if partner.name else '') + last = getattr(s, 'last_name', '') or (' '.join(partner.name.split()[1:]) if partner.name else '') + course_ids = [] + batch_id = None + batch_name = '' + if hasattr(s, 'course_detail_ids'): + for cd in s.course_detail_ids: + if cd.course_id: + course_ids.append(cd.course_id.id) + if cd.batch_id and not batch_id: + batch_id = cd.batch_id.id + batch_name = cd.batch_id.name or '' + return { + 'id': s.id, + 'name': partner.name or '', + 'first_name': first, + 'last_name': last, + 'email': partner.email or '', + 'phone': partner.phone or getattr(partner, 'mobile', '') or '', + 'gender': s.gender or '', + 'enrollment_date': str(s.create_date.date()) if s.create_date else None, + 'status': 'active', + 'course_ids': course_ids, + 'batch_id': batch_id, + 'batch_name': batch_name, + 'partner_id': partner.id, + 'user_id': s.user_id.id if s.user_id else None, + } + + +def _serialize_teacher(f): + partner = f.partner_id + first = partner.name.split()[0] if partner.name else '' + last = ' '.join(partner.name.split()[1:]) if partner.name else '' + dept = getattr(f, 'department_id', False) + return { + 'id': f.id, + 'name': partner.name or '', + 'first_name': first, + 'last_name': last, + 'email': partner.email or '', + 'phone': partner.phone or getattr(partner, 'mobile', '') or '', + 'gender': f.gender or '', + 'birth_date': str(f.birth_date) if hasattr(f, 'birth_date') and f.birth_date else None, + 'partner_id': partner.id, + 'department_id': dept.id if dept else None, + 'department_name': dept.name if dept else '', + 'specialization': getattr(f, 'specialization', '') or '', + 'subject_names': [sub.name for sub in f.subject_ids] if hasattr(f, 'subject_ids') else [], + } + + +def _serialize_batch(b): + SC = b.env['op.student.course'].sudo() + student_recs = SC.search([('batch_id', '=', b.id)], limit=200) + seen = set() + students = [] + for sc in student_recs: + s = sc.student_id + if s and s.exists() and s.id not in seen: + seen.add(s.id) + partner = s.partner_id + students.append({ + 'id': s.id, + 'name': partner.name if partner else '', + 'email': partner.email or '' if partner else '', + }) + return { + 'id': b.id, + 'name': b.name or '', + 'code': b.code or '', + 'course_id': b.course_id.id if b.course_id else 0, + 'course_name': b.course_id.name if b.course_id else '', + 'start_date': str(b.start_date) if b.start_date else '', + 'end_date': str(b.end_date) if b.end_date else '', + 'status': 'active', + 'max_students': getattr(b, 'max_students', 0) or 0, + 'student_count': len(students), + 'students': students, + } + + +class LmsCoreController(http.Controller): + + # ── Courses ────────────────────────────────────────────────────── + + @http.route('/api/courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_courses(self, **kw): + try: + Course = request.env['op.course'].sudo() + domain = [] + if kw.get('status'): + pass # op.course has no status field by default + offset, limit, page = _paginate(kw) + total = Course.search_count(domain) + records = Course.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_course(r) for r in records], + 'data': [_serialize_course(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_courses failed') + return _error_response(str(e), 500) + + @http.route('/api/courses/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_course(self, course_id, **kw): + try: + rec = request.env['op.course'].sudo().browse(course_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _serialize_course(rec)}) + except Exception as e: + _logger.exception('get_course failed') + return _error_response(str(e), 500) + + @http.route('/api/courses', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_course(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'code': body.get('code', ''), + } + if body.get('description'): + vals['description'] = body['description'] + if body.get('max_capacity'): + vals['max_capacity'] = int(body['max_capacity']) + if body.get('encoach_subject_id'): + vals['encoach_subject_id'] = int(body['encoach_subject_id']) + if body.get('difficulty_level'): + vals['difficulty_level'] = body['difficulty_level'] + if body.get('cefr_level'): + vals['cefr_level'] = body['cefr_level'] + if body.get('topic_ids'): + vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])] + if body.get('learning_objective_ids'): + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + if body.get('tag_ids'): + vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] + rec = request.env['op.course'].sudo().create(vals) + return _json_response({'data': _serialize_course(rec)}) + except Exception as e: + _logger.exception('create_course failed') + return _error_response(str(e), 500) + + @http.route('/api/courses/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_course(self, course_id, **kw): + try: + rec = request.env['op.course'].sudo().browse(course_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'description'): + if k in body: + vals[k] = body[k] + if 'max_capacity' in body: + vals['max_capacity'] = int(body['max_capacity']) + if 'encoach_subject_id' in body: + vals['encoach_subject_id'] = int(body['encoach_subject_id']) if body['encoach_subject_id'] else False + if 'difficulty_level' in body: + vals['difficulty_level'] = body['difficulty_level'] or False + if 'cefr_level' in body: + vals['cefr_level'] = body['cefr_level'] or False + if 'topic_ids' in body: + vals['encoach_topic_ids'] = [(6, 0, [int(i) for i in body['topic_ids']])] + if 'learning_objective_ids' in body: + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in body['learning_objective_ids']])] + if 'tag_ids' in body: + vals['encoach_tag_ids'] = [(6, 0, [int(i) for i in body['tag_ids']])] + if vals: + rec.write(vals) + return _json_response({'data': _serialize_course(rec)}) + except Exception as e: + _logger.exception('update_course failed') + return _error_response(str(e), 500) + + @http.route('/api/courses/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_course(self, course_id, **kw): + try: + rec = request.env['op.course'].sudo().browse(course_id) + if not rec.exists(): + return _json_response({'success': True}) + force = str(kw.get('force', '')).lower() in ('1', 'true', 'yes') + enrollments = request.env['op.student.course'].sudo().search([('course_id', '=', course_id)]) + if enrollments and not force: + return _error_response( + f'Cannot delete: {len(enrollments)} student enrollment(s) reference this course. ' + f'Pass force=true to cascade-delete.', 409) + if force and enrollments: + enrollments.unlink() + batches = request.env['op.batch'].sudo().search([('course_id', '=', course_id)]) + if batches and force: + for b in batches: + b.course_id = False + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_course failed') + return _error_response(str(e), 500) + + # ── Student: My Courses (enrollment-filtered) ────────────────── + + @http.route('/api/student/my-courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def student_my_courses(self, **kw): + """Return only courses the current JWT user is enrolled in via op.student.course.""" + try: + user = request.env['res.users'].sudo().browse(request.env.uid) + student = request.env['op.student'].sudo().search([('user_id', '=', user.id)], limit=1) + if not student: + return _json_response({'items': [], 'total': 0}) + course_details = request.env['op.student.course'].sudo().search([ + ('student_id', '=', student.id) + ]) + course_ids = course_details.mapped('course_id') + items = [] + for c in course_ids: + data = _serialize_course(c) + cd = course_details.filtered(lambda d: d.course_id.id == c.id) + data['batch_id'] = cd[0].batch_id.id if cd and cd[0].batch_id else None + data['batch_name'] = cd[0].batch_id.name if cd and cd[0].batch_id else '' + chapters = request.env['encoach.course.chapter'].sudo().search([ + ('course_id', '=', c.id) + ]) + total_materials = sum(ch.material_count for ch in chapters) + progress_recs = request.env['encoach.chapter.progress'].sudo().search([ + ('student_id', '=', student.id), + ('chapter_id', 'in', chapters.ids), + ]) + completed_chapters = len(progress_recs.filtered(lambda p: p.status == 'completed')) + data['chapter_count'] = len(chapters) + data['total_materials'] = total_materials + data['completed_chapters'] = completed_chapters + data['progress'] = int((completed_chapters / len(chapters) * 100) if chapters else 0) + items.append(data) + return _json_response({'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('student_my_courses failed') + return _error_response(str(e), 500) + + # ── Enroll existing student in a course ────────────────────── + + @http.route('/api/students//enroll', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def enroll_student(self, student_id, **kw): + """Enroll an existing student in one or more courses.""" + try: + student = request.env['op.student'].sudo().browse(student_id) + if not student.exists(): + return _error_response('Student not found', 404) + body = _get_json_body() + course_id = body.get('course_id') + course_ids = body.get('course_ids', []) + if course_id: + course_ids.append(int(course_id)) + batch_id = body.get('batch_id') + enrolled = [] + SC = request.env['op.student.course'].sudo() + for cid in course_ids: + cid = int(cid) + existing = SC.search([ + ('student_id', '=', student.id), + ('course_id', '=', cid), + ], limit=1) + if existing: + continue + vals = {'student_id': student.id, 'course_id': cid} + if batch_id: + vals['batch_id'] = int(batch_id) + SC.create(vals) + enrolled.append(cid) + return _json_response({ + 'success': True, + 'enrolled_course_ids': enrolled, + 'student': _serialize_student(student), + }) + except Exception as e: + _logger.exception('enroll_student failed') + return _error_response(str(e), 500) + + # ── Bulk enroll students in a course ───────────────────────── + + @http.route('/api/courses//enroll', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def enroll_students_in_course(self, course_id, **kw): + """Enroll multiple students (or a whole batch) into a course.""" + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Course not found', 404) + body = _get_json_body() + student_ids = [int(sid) for sid in body.get('student_ids', [])] + batch_id = body.get('batch_id') + if batch_id and not student_ids: + students_in_batch = request.env['op.student'].sudo().search([ + ('course_detail_ids.batch_id', '=', int(batch_id)) + ]) + student_ids = students_in_batch.ids + SC = request.env['op.student.course'].sudo() + enrolled = [] + for sid in student_ids: + existing = SC.search([ + ('student_id', '=', sid), + ('course_id', '=', course_id), + ], limit=1) + if existing: + continue + vals = {'student_id': sid, 'course_id': course_id} + if batch_id: + vals['batch_id'] = int(batch_id) + SC.create(vals) + enrolled.append(sid) + return _json_response({ + 'success': True, + 'enrolled_student_ids': enrolled, + 'total_enrolled': len(enrolled), + }) + except Exception as e: + _logger.exception('enroll_students_in_course failed') + return _error_response(str(e), 500) + + # ── Students ───────────────────────────────────────────────────── + + @http.route('/api/students', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_students(self, **kw): + try: + Student = request.env['op.student'].sudo() + domain = [] + if kw.get('search'): + domain = [('partner_id.name', 'ilike', kw['search'])] + if kw.get('batch_id'): + domain.append(('course_detail_ids.batch_id', '=', int(kw['batch_id']))) + offset, limit, page = _paginate(kw) + total = Student.search_count(domain) + records = Student.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_student(r) for r in records], + 'data': [_serialize_student(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_students failed') + return _error_response(str(e), 500) + + @http.route('/api/students/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_student(self, student_id, **kw): + try: + rec = request.env['op.student'].sudo().browse(student_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _serialize_student(rec)}) + except Exception as e: + _logger.exception('get_student failed') + return _error_response(str(e), 500) + + @http.route('/api/students', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_student(self, **kw): + try: + body = _get_json_body() + first = body.get('first_name', '') + last = body.get('last_name', '') + name = f"{first} {last}".strip() + partner_vals = { + 'name': name, + 'email': body.get('email', ''), + 'phone': body.get('phone', ''), + } + partner = request.env['res.partner'].sudo().create(partner_vals) + student_vals = { + 'partner_id': partner.id, + 'gender': body.get('gender', ''), + } + if body.get('birth_date'): + student_vals['birth_date'] = body['birth_date'] + student = request.env['op.student'].sudo().create(student_vals) + if body.get('course_id'): + cd_vals = { + 'student_id': student.id, + 'course_id': int(body['course_id']), + } + if body.get('batch_id'): + cd_vals['batch_id'] = int(body['batch_id']) + request.env['op.student.course'].sudo().create(cd_vals) + if body.get('create_portal_user', True) and body.get('email'): + user = request.env['res.users'].sudo().create({ + 'name': name, + 'login': body['email'], + 'email': body['email'], + 'password': body.get('password', 'student123'), + 'partner_id': partner.id, + }) + student.sudo().write({'user_id': user.id}) + return _json_response({'data': _serialize_student(student)}) + except Exception as e: + _logger.exception('create_student failed') + return _error_response(str(e), 500) + + @http.route('/api/students/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_student(self, student_id, **kw): + try: + rec = request.env['op.student'].sudo().browse(student_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + partner_vals = {} + if 'first_name' in body or 'last_name' in body: + first = body.get('first_name', rec.partner_id.name.split()[0] if rec.partner_id.name else '') + last = body.get('last_name', ' '.join(rec.partner_id.name.split()[1:]) if rec.partner_id.name else '') + partner_vals['name'] = f"{first} {last}".strip() + if 'email' in body: + partner_vals['email'] = body['email'] + if 'phone' in body: + partner_vals['phone'] = body['phone'] + if partner_vals: + rec.partner_id.sudo().write(partner_vals) + student_vals = {} + if 'gender' in body: + student_vals['gender'] = body['gender'] + if student_vals: + rec.write(student_vals) + return _json_response({'data': _serialize_student(rec)}) + except Exception as e: + _logger.exception('update_student failed') + return _error_response(str(e), 500) + + @http.route('/api/students/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_student(self, student_id, **kw): + try: + rec = request.env['op.student'].sudo().browse(student_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_student failed') + return _error_response(str(e), 500) + + # ── Teachers ───────────────────────────────────────────────────── + + @http.route('/api/teachers', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_teachers(self, **kw): + try: + Faculty = request.env['op.faculty'].sudo() + domain = [] + if kw.get('search'): + domain = [('partner_id.name', 'ilike', kw['search'])] + offset, limit, page = _paginate(kw) + total = Faculty.search_count(domain) + records = Faculty.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_teacher(r) for r in records], + 'data': [_serialize_teacher(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_teachers failed') + return _error_response(str(e), 500) + + @http.route('/api/teachers', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_teacher(self, **kw): + try: + body = _get_json_body() + first = body.get('first_name', '') + last = body.get('last_name', '') + name = f"{first} {last}".strip() + partner = request.env['res.partner'].sudo().create({ + 'name': name, + 'email': body.get('email', ''), + 'phone': body.get('phone', ''), + }) + fac_vals = { + 'partner_id': partner.id, + 'gender': body.get('gender', ''), + } + if body.get('department_id') and hasattr(request.env['op.faculty'], 'department_id'): + fac_vals['department_id'] = int(body['department_id']) + if body.get('birth_date'): + fac_vals['birth_date'] = body['birth_date'] + faculty = request.env['op.faculty'].sudo().create(fac_vals) + return _json_response({'data': _serialize_teacher(faculty)}) + except Exception as e: + _logger.exception('create_teacher failed') + return _error_response(str(e), 500) + + @http.route('/api/teachers/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_teacher(self, teacher_id, **kw): + try: + rec = request.env['op.faculty'].sudo().browse(teacher_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_teacher failed') + return _error_response(str(e), 500) + + # ── Batches ────────────────────────────────────────────────────── + + @http.route('/api/batches', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_batches(self, **kw): + try: + Batch = request.env['op.batch'].sudo() + domain = [] + offset, limit, page = _paginate(kw) + total = Batch.search_count(domain) + records = Batch.search(domain, offset=offset, limit=limit, order='id desc') + return _json_response({ + 'items': [_serialize_batch(r) for r in records], + 'data': [_serialize_batch(r) for r in records], + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_batches failed') + return _error_response(str(e), 500) + + @http.route('/api/batches/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_batch(self, batch_id, **kw): + try: + rec = request.env['op.batch'].sudo().browse(batch_id) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _serialize_batch(rec)}) + except Exception as e: + _logger.exception('get_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_batch(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '')} + if body.get('code'): + vals['code'] = body['code'] + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('start_date'): + vals['start_date'] = body['start_date'] + if body.get('end_date'): + vals['end_date'] = body['end_date'] + rec = request.env['op.batch'].sudo().create(vals) + return _json_response({'data': _serialize_batch(rec)}) + except Exception as e: + _logger.exception('create_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_batch(self, batch_id, **kw): + try: + rec = request.env['op.batch'].sudo().browse(batch_id) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'start_date', 'end_date'): + if k in body: + vals[k] = body[k] + if 'course_id' in body: + vals['course_id'] = int(body['course_id']) + if vals: + rec.write(vals) + return _json_response({'data': _serialize_batch(rec)}) + except Exception as e: + _logger.exception('update_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_batch(self, batch_id, **kw): + try: + rec = request.env['op.batch'].sudo().browse(batch_id) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches//students', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_batch_students(self, batch_id, **kw): + try: + SC = request.env['op.student.course'].sudo() + recs = SC.search([('batch_id', '=', batch_id)]) + students = [] + for sc in recs: + s = sc.student_id + if s and s.exists(): + partner = s.partner_id + students.append({ + 'id': s.id, + 'name': partner.name if partner else '', + 'email': partner.email or '' if partner else '', + 'course_id': sc.course_id.id if sc.course_id else 0, + 'course_name': sc.course_id.name if sc.course_id else '', + }) + return _json_response({'data': students, 'total': len(students)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/batches//students', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def add_students_to_batch(self, batch_id, **kw): + """Add students to a batch. Creates op.student.course links with batch_id.""" + try: + batch = request.env['op.batch'].sudo().browse(batch_id) + if not batch.exists(): + return _error_response('Batch not found', 404) + body = _get_json_body() + student_ids = [int(s) for s in body.get('student_ids', [])] + SC = request.env['op.student.course'].sudo() + added = [] + for sid in student_ids: + existing = SC.search([ + ('student_id', '=', sid), + ('batch_id', '=', batch_id), + ], limit=1) + if existing: + continue + vals = {'student_id': sid, 'batch_id': batch_id} + if batch.course_id: + vals['course_id'] = batch.course_id.id + dup = SC.search([ + ('student_id', '=', sid), + ('course_id', '=', batch.course_id.id), + ], limit=1) + if dup: + dup.write({'batch_id': batch_id}) + added.append(sid) + continue + SC.create(vals) + added.append(sid) + return _json_response({ + 'success': True, + 'added_ids': added, + 'total': len(added), + 'data': _serialize_batch(batch), + }) + except Exception as e: + _logger.exception('add_students_to_batch failed') + return _error_response(str(e), 500) + + @http.route('/api/batches//students/remove', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def remove_students_from_batch(self, batch_id, **kw): + """Remove students from a batch by clearing their batch_id.""" + try: + body = _get_json_body() + student_ids = [int(s) for s in body.get('student_ids', [])] + SC = request.env['op.student.course'].sudo() + removed = [] + for sid in student_ids: + recs = SC.search([ + ('student_id', '=', sid), + ('batch_id', '=', batch_id), + ]) + if recs: + recs.write({'batch_id': False}) + removed.append(sid) + batch = request.env['op.batch'].sudo().browse(batch_id) + return _json_response({ + 'success': True, + 'removed_ids': removed, + 'total': len(removed), + 'data': _serialize_batch(batch) if batch.exists() else {}, + }) + except Exception as e: + return _error_response(str(e), 500) + + # ── Taxonomy Relationship Endpoints ───────────────────────────── + + @http.route('/api/courses//taxonomy', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def course_taxonomy(self, course_id, **kw): + """Return the taxonomy tree relevant to a course: its subject, topics, and objectives.""" + try: + course = request.env['op.course'].sudo().browse(course_id) + if not course.exists(): + return _error_response('Not found', 404) + subj = course.encoach_subject_id if hasattr(course, 'encoach_subject_id') else False + topics = course.encoach_topic_ids if hasattr(course, 'encoach_topic_ids') else course.env['encoach.topic'] + objectives = course.learning_objective_ids if hasattr(course, 'learning_objective_ids') else course.env['encoach.learning.objective'] + domains = topics.mapped('domain_id') + return _json_response({ + 'subject': {'id': subj.id, 'name': subj.name, 'code': subj.code or ''} if subj else None, + 'domains': [{'id': d.id, 'name': d.name} for d in domains], + 'topics': [{'id': t.id, 'name': t.name, 'domain_id': t.domain_id.id, 'domain_name': t.domain_id.name} for t in topics], + 'objectives': [{'id': o.id, 'name': o.name, 'topic_id': o.topic_id.id, 'topic_name': o.topic_id.name, 'bloom_level': o.bloom_level or ''} for o in objectives], + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in (course.encoach_tag_ids if hasattr(course, 'encoach_tag_ids') else [])], + }) + except Exception as e: + _logger.exception('course_taxonomy failed') + return _error_response(str(e), 500) + + @http.route('/api/subjects//courses', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def subject_courses(self, subject_id, **kw): + """Return all courses linked to a given taxonomy subject.""" + try: + courses = request.env['op.course'].sudo().search([ + ('encoach_subject_id', '=', subject_id) + ]) + return _json_response({ + 'items': [_serialize_course(c) for c in courses], + 'total': len(courses), + }) + except Exception as e: + _logger.exception('subject_courses failed') + return _error_response(str(e), 500) + + @http.route('/api/topics//resources', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def topic_resources(self, topic_id, **kw): + """Return all resources linked to a given taxonomy topic.""" + try: + resources = request.env['encoach.resource'].sudo().search([ + ('topic_ids', 'in', [topic_id]) + ]) + from odoo.addons.encoach_lms_api.controllers.resources import _ser_resource + return _json_response({ + 'items': [_ser_resource(r) for r in resources], + 'total': len(resources), + }) + except Exception as e: + _logger.exception('topic_resources failed') + return _error_response(str(e), 500) + + @http.route('/api/learning-objectives', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_learning_objectives(self, **kw): + """List learning objectives, optionally filtered by topic_id or subject_id.""" + try: + LO = request.env['encoach.learning.objective'].sudo() + domain = [('active', '=', True)] + if kw.get('topic_id'): + domain.append(('topic_id', '=', int(kw['topic_id']))) + elif kw.get('topic_ids'): + ids = [int(x) for x in str(kw['topic_ids']).split(',') if x.strip()] + domain.append(('topic_id', 'in', ids)) + elif kw.get('subject_id'): + topics = request.env['encoach.topic'].sudo().search([ + ('domain_id.subject_id', '=', int(kw['subject_id'])) + ]) + domain.append(('topic_id', 'in', topics.ids)) + recs = LO.search(domain, order='sequence, name') + items = [{ + 'id': o.id, + 'name': o.name, + 'description': o.description or '', + 'topic_id': o.topic_id.id, + 'topic_name': o.topic_id.name, + 'bloom_level': o.bloom_level or '', + 'cefr_level': o.cefr_level or '', + } for o in recs] + return _json_response({'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('list_learning_objectives failed') + return _error_response(str(e), 500) + + @http.route('/api/domains', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_domains_lms(self, **kw): + """List domains, optionally filtered by subject_id.""" + try: + D = request.env['encoach.domain'].sudo() + domain = [('active', '=', True)] + if kw.get('subject_id'): + domain.append(('subject_id', '=', int(kw['subject_id']))) + recs = D.search(domain, order='sequence, name') + items = [{ + 'id': d.id, + 'name': d.name, + 'subject_id': d.subject_id.id, + 'subject_name': d.subject_id.name, + 'description': d.description or '', + } for d in recs] + return _json_response({'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('list_domains_lms failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/notifications.py b/custom_addons/encoach_lms_api/controllers/notifications.py new file mode 100644 index 0000000..19a1c81 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/notifications.py @@ -0,0 +1,203 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_notif(n): + return { + 'id': n.id, + 'title': n.title or '', + 'message': n.message or '', + 'type': n.type or 'info', + 'is_read': n.is_read, + 'read_at': str(n.read_at) if n.read_at else None, + 'reference_model': n.reference_model or None, + 'reference_id': n.reference_id or None, + 'created_at': str(n.create_date) if n.create_date else '', + } + + +def _ser_rule(r): + return { + 'id': r.id, + 'name': r.name or '', + 'event_type': r.event_type or '', + 'template': r.template or '', + 'is_active': r.is_active, + 'recipients': r.recipients or 'all', + 'channels': r.channels or '', + } + + +class NotificationsController(http.Controller): + + @http.route('/api/notifications', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_notifications(self, **kw): + try: + M = request.env['encoach.notification'].sudo() + domain = [('user_id', '=', request.env.uid)] + if kw.get('type'): + domain.append(('type', '=', kw['type'])) + if kw.get('is_read') == 'false': + domain.append(('is_read', '=', False)) + elif kw.get('is_read') == 'true': + domain.append(('is_read', '=', True)) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='create_date desc') + items = [_ser_notif(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notifications//read', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_read(self, nid, **kw): + try: + rec = request.env['encoach.notification'].sudo().browse(nid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import datetime + rec.write({'is_read': True, 'read_at': str(datetime.now())}) + return _json_response(_ser_notif(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notifications/read-all', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_all_read(self, **kw): + try: + from datetime import datetime + recs = request.env['encoach.notification'].sudo().search([ + ('user_id', '=', request.env.uid), ('is_read', '=', False) + ]) + recs.write({'is_read': True, 'read_at': str(datetime.now())}) + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notifications/unread-count', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def unread_count(self, **kw): + try: + count = request.env['encoach.notification'].sudo().search_count([ + ('user_id', '=', request.env.uid), ('is_read', '=', False) + ]) + return _json_response({'count': count}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Rules ──────────────────────────────────────────────────────── + + @http.route('/api/notification-rules', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_rules(self, **kw): + try: + recs = request.env['encoach.notification.rule'].sudo().search([], order='id desc') + return _json_response([_ser_rule(r) for r in recs]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notification-rules', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_rule(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'event_type': body.get('event_type', ''), + } + if body.get('template'): + vals['template'] = body['template'] + if body.get('recipients'): + vals['recipients'] = body['recipients'] + if body.get('channels'): + vals['channels'] = body['channels'] + if 'is_active' in body: + vals['is_active'] = bool(body['is_active']) + rec = request.env['encoach.notification.rule'].sudo().create(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notification-rules/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_rule(self, rid, **kw): + try: + rec = request.env['encoach.notification.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'event_type', 'template', 'recipients', 'channels'): + if k in body: + vals[k] = body[k] + if 'is_active' in body: + vals['is_active'] = bool(body['is_active']) + if vals: + rec.write(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notification-rules/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_rule(self, rid, **kw): + try: + rec = request.env['encoach.notification.rule'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Preferences ────────────────────────────────────────────────── + + @http.route('/api/notification-preferences', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_preferences(self, **kw): + try: + M = request.env['encoach.notification.preference'].sudo() + rec = M.search([('user_id', '=', request.env.uid)], limit=1) + if not rec: + rec = M.create({'user_id': request.env.uid}) + return _json_response({ + 'email_enabled': rec.email_enabled, + 'push_enabled': rec.push_enabled, + 'in_app_enabled': rec.in_app_enabled, + 'digest_frequency': rec.digest_frequency, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/notification-preferences', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_preferences(self, **kw): + try: + M = request.env['encoach.notification.preference'].sudo() + rec = M.search([('user_id', '=', request.env.uid)], limit=1) + if not rec: + rec = M.create({'user_id': request.env.uid}) + body = _get_json_body() + vals = {} + for k in ('email_enabled', 'push_enabled', 'in_app_enabled'): + if k in body: + vals[k] = bool(body[k]) + if 'digest_frequency' in body: + vals['digest_frequency'] = body['digest_frequency'] + if vals: + rec.write(vals) + return _json_response({ + 'email_enabled': rec.email_enabled, + 'push_enabled': rec.push_enabled, + 'in_app_enabled': rec.in_app_enabled, + 'digest_frequency': rec.digest_frequency, + }) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/payments.py b/custom_addons/encoach_lms_api/controllers/payments.py new file mode 100644 index 0000000..ea73021 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/payments.py @@ -0,0 +1,125 @@ +"""Payment Record endpoints — surfaces real accounting/fees data to the +`/admin/payment-record` page (previously mocked). +""" +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__) + + +def _ser_fee_as_payment(rec): + """Serialize an op.student.fees.details row as a 'payment record'.""" + inv = rec.invoice_id if hasattr(rec, 'invoice_id') else False + return { + 'id': rec.id, + 'ref': f'FEE-{rec.id:04d}', + 'student_id': rec.student_id.id if rec.student_id else None, + 'student_name': rec.student_id.name if rec.student_id else '', + 'course_id': rec.course_id.id if rec.course_id else None, + 'course_name': rec.course_id.name if rec.course_id else '', + 'product_name': rec.product_id.name if rec.product_id else '', + 'amount': float(rec.amount or 0.0), + 'after_discount_amount': float(getattr(rec, 'after_discount_amount', rec.amount) or 0.0), + 'currency': rec.currency_id.name if hasattr(rec, 'currency_id') and rec.currency_id else 'USD', + 'state': rec.state or 'draft', + 'paid': rec.state in ('paid', 'post'), + 'date': str(rec.date) if rec.date else '', + 'type': 'Student Fees', + 'invoice_id': inv.id if inv else None, + 'invoice_number': inv.name if inv and inv.name and inv.name != '/' else '', + 'payment_state': inv.payment_state if inv and hasattr(inv, 'payment_state') else '', + } + + +def _ser_invoice(inv): + return { + 'id': inv.id, + 'ref': inv.name or f'INV-{inv.id}', + 'partner_name': inv.partner_id.name if inv.partner_id else '', + 'amount': float(inv.amount_total or 0.0), + 'currency': inv.currency_id.name if inv.currency_id else 'USD', + 'state': inv.state or 'draft', + 'payment_state': getattr(inv, 'payment_state', '') or '', + 'paid': getattr(inv, 'payment_state', '') == 'paid', + 'date': str(inv.invoice_date) if inv.invoice_date else str(inv.date) if inv.date else '', + 'type': 'Invoice', + } + + +class PaymentRecordsController(http.Controller): + + @http.route('/api/payment-records', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_payments(self, **kw): + """Return a unified list of payment records sourced from student fees + (and optionally accounting invoices). Supports filters: status, paid, + student_id.""" + try: + FeesModel = request.env['op.student.fees.details'].sudo() + domain = [] + status = kw.get('status') + if status and status != 'all': + domain.append(('state', '=', status)) + if kw.get('student_id'): + try: + domain.append(('student_id', '=', int(kw['student_id']))) + except (TypeError, ValueError): + pass + recs = FeesModel.search(domain, limit=200) + items = [_ser_fee_as_payment(r) for r in recs] + paid_filter = kw.get('paid') + if paid_filter in ('true', 'false'): + want = paid_filter == 'true' + items = [i for i in items if i['paid'] == want] + return _json_response({ + 'data': items, + 'items': items, + 'total': len(items), + 'totals': { + 'count': len(items), + 'paid': sum(1 for i in items if i['paid']), + 'unpaid': sum(1 for i in items if not i['paid']), + 'amount': sum(i['amount'] for i in items), + }, + }) + except Exception as e: + _logger.exception('list_payments') + return _error_response(str(e), 500) + + @http.route('/api/payment-records/invoices', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_invoices(self, **kw): + """Raw accounting invoices (account.move with out_invoice).""" + try: + Move = request.env['account.move'].sudo() + domain = [('move_type', '=', 'out_invoice')] + try: + size = min(int(kw.get('size') or 50), 200) + except (TypeError, ValueError): + size = 50 + recs = Move.search(domain, limit=size, order='id desc') + items = [_ser_invoice(m) for m in recs] + return _json_response({ + 'data': items, 'items': items, 'total': len(items), + }) + except Exception as e: + _logger.exception('list_invoices') + return _error_response(str(e), 500) + + @http.route('/api/paymob-orders', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_paymob_orders(self, **kw): + """Paymob integration is not yet wired — return empty list. + This endpoint exists so the UI renders cleanly instead of calling a + 404-returning route.""" + return _json_response({ + 'data': [], 'items': [], 'total': 0, + 'message': 'Paymob integration not configured.', + }) diff --git a/custom_addons/encoach_lms_api/controllers/platform_settings.py b/custom_addons/encoach_lms_api/controllers/platform_settings.py new file mode 100644 index 0000000..9df4637 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/platform_settings.py @@ -0,0 +1,232 @@ +"""Platform settings endpoints powering `/admin/settings-platform`. + +Exposes three groups: + * Codes (backed by `encoach.code`) + * Packages (simple persisted list in `ir.config_parameter`) + * Grading config (min / max / increment, in `ir.config_parameter`) +""" +import json +import logging +import uuid + +from odoo import http, fields as odoo_fields +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__) + +_PKG_PARAM = 'encoach.platform.packages' +_GRADE_PARAM = 'encoach.platform.grading' +_DEFAULT_PACKAGES = [ + {'id': 1, 'name': 'IELTS Starter', 'price': 99, 'duration': '1 month', 'discount': 0}, + {'id': 2, 'name': 'IELTS Pro', 'price': 249, 'duration': '3 months', 'discount': 15}, + {'id': 3, 'name': 'Corporate Bundle','price': 1999, 'duration': '12 months','discount': 25}, +] +_DEFAULT_GRADING = {'min_score': 0, 'max_score': 9, 'increment': 0.5} + + +def _ser_code(c): + return { + 'id': c.id, + 'code': c.code or '', + 'code_type': c.code_type or 'individual', + 'user_type': c.user_type or 'student', + 'max_uses': c.max_uses or 1, + 'uses': c.uses or 0, + 'used': bool(c.uses and c.max_uses and c.uses >= c.max_uses), + 'expiry_date': c.expiry_date.isoformat() if c.expiry_date else '', + 'created': c.create_date.isoformat() if c.create_date else '', + 'entity_id': c.entity_id.id if c.entity_id else None, + } + + +class PlatformSettingsController(http.Controller): + + # ── Codes ──────────────────────────────────────────────────────────── + @http.route('/api/codes', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_codes(self, **kw): + try: + Code = request.env['encoach.code'].sudo() + domain = [] + ct = kw.get('code_type') + if ct and ct != 'all': + domain.append(('code_type', '=', ct)) + used = kw.get('used') + try: + size = min(int(kw.get('size') or 100), 500) + except (TypeError, ValueError): + size = 100 + recs = Code.search(domain, limit=size, order='id desc') + items = [_ser_code(c) for c in recs] + if used in ('true', 'false'): + want = used == 'true' + items = [i for i in items if i['used'] == want] + return _json_response({ + 'data': items, 'items': items, 'total': len(items), + }) + except Exception as e: + _logger.exception('list_codes') + return _error_response(str(e), 500) + + @http.route('/api/codes/generate', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def generate_codes(self, **kw): + """Body: { count?: int, code_type?: 'individual'|'corporate', + user_type?: 'student'|'teacher'|'corporate', + max_uses?: int, expiry_date?: ISO-str }""" + try: + body = _get_json_body() + count = max(1, min(int(body.get('count') or 1), 200)) + vals_common = { + 'creator_id': request.env.user.id, + 'code_type': body.get('code_type') or 'individual', + 'user_type': body.get('user_type') or 'student', + 'max_uses': int(body.get('max_uses') or 1), + } + if body.get('expiry_date'): + vals_common['expiry_date'] = body['expiry_date'] + if body.get('entity_id'): + vals_common['entity_id'] = int(body['entity_id']) + + created = [] + for _ in range(count): + vals = dict(vals_common, code=uuid.uuid4().hex[:8].upper()) + rec = request.env['encoach.code'].sudo().create(vals) + created.append(_ser_code(rec)) + return _json_response({'data': created, 'count': len(created)}) + except Exception as e: + _logger.exception('generate_codes') + return _error_response(str(e), 500) + + @http.route('/api/codes/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_code(self, cid, **kw): + try: + rec = request.env['encoach.code'].sudo().browse(cid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_code') + return _error_response(str(e), 500) + + # ── Packages (ir.config_parameter JSON blob) ───────────────────────── + def _read_packages(self): + Param = request.env['ir.config_parameter'].sudo() + raw = Param.get_param(_PKG_PARAM) + if not raw: + return list(_DEFAULT_PACKAGES) + try: + return json.loads(raw) + except Exception: + return list(_DEFAULT_PACKAGES) + + def _write_packages(self, packages): + request.env['ir.config_parameter'].sudo().set_param( + _PKG_PARAM, json.dumps(packages)) + + @http.route('/api/packages', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_packages(self, **kw): + pkgs = self._read_packages() + return _json_response({'data': pkgs, 'items': pkgs, 'total': len(pkgs)}) + + @http.route('/api/packages', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_package(self, **kw): + try: + body = _get_json_body() + pkgs = self._read_packages() + next_id = max((p.get('id', 0) for p in pkgs), default=0) + 1 + pkg = { + 'id': next_id, + 'name': body.get('name') or f'Package {next_id}', + 'price': float(body.get('price') or 0), + 'duration': body.get('duration') or '1 month', + 'discount': float(body.get('discount') or 0), + } + pkgs.append(pkg) + self._write_packages(pkgs) + return _json_response(pkg) + except Exception as e: + _logger.exception('create_package') + return _error_response(str(e), 500) + + @http.route('/api/packages/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_package(self, pid, **kw): + try: + body = _get_json_body() + pkgs = self._read_packages() + for p in pkgs: + if p.get('id') == pid: + for k in ('name', 'duration'): + if k in body: + p[k] = body[k] + for k in ('price', 'discount'): + if k in body: + p[k] = float(body[k]) + self._write_packages(pkgs) + return _json_response(p) + return _error_response('Not found', 404) + except Exception as e: + _logger.exception('update_package') + return _error_response(str(e), 500) + + @http.route('/api/packages/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_package(self, pid, **kw): + pkgs = self._read_packages() + new_pkgs = [p for p in pkgs if p.get('id') != pid] + self._write_packages(new_pkgs) + return _json_response({'success': True}) + + # ── Grading (ir.config_parameter JSON blob) ─────────────────────────── + @http.route('/api/grading-config', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def get_grading(self, **kw): + Param = request.env['ir.config_parameter'].sudo() + raw = Param.get_param(_GRADE_PARAM) + try: + cfg = json.loads(raw) if raw else dict(_DEFAULT_GRADING) + except Exception: + cfg = dict(_DEFAULT_GRADING) + return _json_response({'data': cfg, **cfg}) + + @http.route('/api/grading-config', type='http', auth='public', + methods=['PATCH', 'PUT', 'POST'], csrf=False) + @jwt_required + def set_grading(self, **kw): + try: + body = _get_json_body() + Param = request.env['ir.config_parameter'].sudo() + raw = Param.get_param(_GRADE_PARAM) + cfg = dict(_DEFAULT_GRADING) + if raw: + try: + cfg.update(json.loads(raw)) + except Exception: + pass + for k in ('min_score', 'max_score'): + if k in body: + cfg[k] = int(body[k]) + if 'increment' in body: + cfg['increment'] = float(body['increment']) + if cfg['min_score'] >= cfg['max_score']: + return _error_response('min_score must be less than max_score', 400) + Param.set_param(_GRADE_PARAM, json.dumps(cfg)) + return _json_response({'data': cfg, **cfg}) + except Exception as e: + _logger.exception('set_grading') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/resources.py b/custom_addons/encoach_lms_api/controllers/resources.py new file mode 100644 index 0000000..b205552 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/resources.py @@ -0,0 +1,286 @@ +import base64 +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_resource(r): + tags = r.tag_ids if r.tag_ids else r.env['encoach.resource.tag'] + objectives = r.learning_objective_ids if r.learning_objective_ids else r.env['encoach.learning.objective'] + return { + 'id': r.id, + 'name': r.name or '', + 'resource_type': r.type or 'document', + 'type': r.type or 'document', + 'subject_id': r.subject_id.id if r.subject_id else None, + 'subject_name': r.subject_id.name if r.subject_id else '', + 'domain_id': r.domain_id.id if r.domain_id else None, + 'domain_name': r.domain_id.name if r.domain_id else '', + 'topic_ids': r.topic_ids.ids if r.topic_ids else [], + 'topic_names': r.topic_ids.mapped('name') if r.topic_ids else [], + 'learning_objective_ids': objectives.ids, + 'learning_objective_names': objectives.mapped('name'), + 'tag_ids': tags.ids, + 'tag_names': tags.mapped('name'), + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in tags], + 'url': r.url or '', + 'has_file': bool(r.file), + 'difficulty': r.difficulty or '', + 'duration_minutes': r.duration_minutes or 0, + 'author_id': r.creator_id.id if r.creator_id else None, + 'author_name': r.creator_id.name if r.creator_id else '', + 'review_status': r.review_status or 'approved', + 'cefr_level': r.cefr_level or '', + 'ai_generated': r.ai_generated, + 'course_count': r.course_count or 0, + 'created_at': r.create_date.isoformat() if r.create_date else '', + } + + +class ResourcesController(http.Controller): + + @http.route('/api/resources', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_resources(self, **kw): + try: + M = request.env['encoach.resource'].sudo() + domain = [] + if kw.get('subject_id'): + domain.append(('subject_id', '=', int(kw['subject_id']))) + if kw.get('topic_id'): + domain.append(('topic_ids', 'in', [int(kw['topic_id'])])) + if kw.get('tag_id'): + domain.append(('tag_ids', 'in', [int(kw['tag_id'])])) + if kw.get('resource_type'): + domain.append(('type', '=', kw['resource_type'])) + if kw.get('review_status'): + domain.append(('review_status', '=', kw['review_status'])) + if kw.get('date_from'): + domain.append(('create_date', '>=', kw['date_from'])) + if kw.get('date_to'): + domain.append(('create_date', '<=', kw['date_to'])) + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_resource(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_resources') + return _error_response(str(e), 500) + + @http.route('/api/resources', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_resource(self, **kw): + try: + files = request.httprequest.files + f = files.get('file') + ct = request.httprequest.content_type or '' + body = {} + if 'application/json' in ct: + body = _get_json_body() + params = {**body, **kw} + + vals = {'name': params.get('name', f.filename if f else 'Resource')} + rtype = params.get('resource_type') or params.get('type') + if rtype: + vals['type'] = rtype + if params.get('subject_id'): + vals['subject_id'] = int(params['subject_id']) + if params.get('topic_id'): + vals['topic_ids'] = [(4, int(params['topic_id']))] + if params.get('topic_ids'): + ids = params['topic_ids'] + if isinstance(ids, str): + ids = [int(x) for x in ids.split(',') if x.strip()] + elif isinstance(ids, list): + ids = [int(x) for x in ids] + vals['topic_ids'] = [(6, 0, ids)] + if params.get('tag_ids'): + ids = params['tag_ids'] + if isinstance(ids, str): + ids = [int(x) for x in ids.split(',') if x.strip()] + elif isinstance(ids, list): + ids = [int(x) for x in ids] + vals['tag_ids'] = [(6, 0, ids)] + if params.get('domain_id'): + vals['domain_id'] = int(params['domain_id']) + if params.get('learning_objective_ids'): + ids = params['learning_objective_ids'] + if isinstance(ids, str): + ids = [int(x) for x in ids.split(',') if x.strip()] + elif isinstance(ids, list): + ids = [int(x) for x in ids] + vals['learning_objective_ids'] = [(6, 0, ids)] + if params.get('url'): + vals['url'] = params['url'] + if params.get('difficulty'): + vals['difficulty'] = params['difficulty'] + if params.get('cefr_level'): + vals['cefr_level'] = params['cefr_level'] + if f: + vals['file'] = base64.b64encode(f.read()) + rec = request.env['encoach.resource'].sudo().create(vals) + return _json_response({'data': _ser_resource(rec)}) + except Exception as e: + _logger.exception('create_resource') + return _error_response(str(e), 500) + + @http.route('/api/resources/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_resource(self, rid, **kw): + try: + rec = request.env['encoach.resource'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'url', 'review_status', 'difficulty', 'cefr_level'): + if k in body: + vals[k] = body[k] + if 'resource_type' in body or 'type' in body: + vals['type'] = body.get('resource_type') or body.get('type') + if 'subject_id' in body: + vals['subject_id'] = int(body['subject_id']) if body['subject_id'] else False + if 'tag_ids' in body: + ids = body['tag_ids'] + vals['tag_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)] + if 'domain_id' in body: + vals['domain_id'] = int(body['domain_id']) if body['domain_id'] else False + if 'topic_ids' in body: + ids = body['topic_ids'] + vals['topic_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)] + elif 'topic_id' in body: + vals['topic_ids'] = [(4, int(body['topic_id']))] if body['topic_id'] else [(5,)] + if 'learning_objective_ids' in body: + ids = body['learning_objective_ids'] + vals['learning_objective_ids'] = [(6, 0, [int(i) for i in ids])] if ids else [(5,)] + if vals: + rec.write(vals) + return _json_response({'data': _ser_resource(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_resource(self, rid, **kw): + try: + rec = request.env['encoach.resource'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources//complete', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def mark_complete(self, rid, **kw): + try: + return _json_response({'data': {'id': rid, 'completed': True}}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources//rate', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def rate_resource(self, rid, **kw): + try: + body = _get_json_body() + return _json_response({'success': True, 'rating': body.get('rating', 0)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resources//download', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def download_resource(self, rid, **kw): + try: + rec = request.env['encoach.resource'].sudo().browse(rid) + if not rec.exists() or not rec.file: + return _error_response('No file', 404) + import mimetypes + ext = (rec.name or '').rsplit('.', 1)[-1].lower() if '.' in (rec.name or '') else '' + mime = mimetypes.types_map.get(f'.{ext}', 'application/octet-stream') + data = base64.b64decode(rec.file) + return request.make_response(data, [ + ('Content-Type', mime), + ('Content-Disposition', f'attachment; filename="{rec.name}"'), + ('Content-Length', str(len(data))), + ]) + except Exception as e: + _logger.exception('download_resource') + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # Resource Tags CRUD + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/resource-tags', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_tags(self, **kw): + try: + Tag = request.env['encoach.resource.tag'].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + recs = Tag.search(domain, order='name') + return _json_response({ + 'data': [t.to_api_dict() for t in recs], + 'items': [t.to_api_dict() for t in recs], + }) + except Exception as e: + _logger.exception('list_tags') + return _error_response(str(e), 500) + + @http.route('/api/resource-tags', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_tag(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', '').strip()} + if not vals['name']: + return _error_response('name is required', 400) + if body.get('color'): + vals['color'] = body['color'] + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.resource.tag'].sudo().create(vals) + return _json_response({'data': rec.to_api_dict()}, 201) + except Exception as e: + _logger.exception('create_tag') + return _error_response(str(e), 500) + + @http.route('/api/resource-tags/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_tag(self, tid, **kw): + try: + rec = request.env['encoach.resource.tag'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Tag not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'color', 'description'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response({'data': rec.to_api_dict()}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/resource-tags/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_tag(self, tid, **kw): + try: + rec = request.env['encoach.resource.tag'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/stats.py b/custom_addons/encoach_lms_api/controllers/stats.py new file mode 100644 index 0000000..76f93f0 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/stats.py @@ -0,0 +1,112 @@ +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 StatsController(http.Controller): + + @http.route('/api/sessions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_sessions(self, **kw): + try: + return _json_response([]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/stats', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_stats(self, **kw): + try: + env = request.env + course_count = env['op.course'].sudo().search_count([]) + student_count = env['op.student'].sudo().search_count([]) + faculty_count = env['op.faculty'].sudo().search_count([]) + batch_count = env['op.batch'].sudo().search_count([]) + return _json_response([{ + 'label': 'courses', 'value': course_count, + }, { + 'label': 'students', 'value': student_count, + }, { + 'label': 'teachers', 'value': faculty_count, + }, { + 'label': 'batches', 'value': batch_count, + }]) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/statistical', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_statistical(self, **kw): + try: + env = request.env + return _json_response({ + 'total_students': env['op.student'].sudo().search_count([]), + 'total_courses': env['op.course'].sudo().search_count([]), + 'total_teachers': env['op.faculty'].sudo().search_count([]), + 'total_batches': env['op.batch'].sudo().search_count([]), + 'exam_count': 0, + 'pass_rate': 0, + 'avg_score': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/stats/performance', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_performance(self, **kw): + try: + return _json_response([]) + except Exception as e: + return _error_response(str(e), 500) + + # ── Analytics ──────────────────────────────────────────────────── + + @http.route('/api/analytics/student', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_student(self, **kw): + try: + return _json_response({ + 'total_courses': 0, + 'completed_courses': 0, + 'average_score': 0, + 'attendance_rate': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/analytics/class', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_class(self, **kw): + try: + return _json_response({ + 'total_students': 0, + 'average_score': 0, + 'pass_rate': 0, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/analytics/subject', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_subject(self, **kw): + try: + return _json_response({ + 'subjects': [], + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/analytics/content-gaps', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def analytics_content_gaps(self, **kw): + try: + return _json_response({ + 'gaps': [], + }) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/student_leave.py b/custom_addons/encoach_lms_api/controllers/student_leave.py new file mode 100644 index 0000000..c4f8a59 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/student_leave.py @@ -0,0 +1,144 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _ser_leave(r): + return { + 'id': r.id, + 'request_number': r.request_number or '', + 'student_id': r.student_id.id if r.student_id else 0, + 'student_name': r.student_id.partner_id.name if r.student_id and r.student_id.partner_id else '', + 'leave_type': r.leave_type_id.name if r.leave_type_id else '', + 'leave_type_id': r.leave_type_id.id if r.leave_type_id else 0, + 'start_date': str(r.start_date) if r.start_date else '', + 'end_date': str(r.end_date) if r.end_date else '', + 'duration': r.duration or 0, + 'description': r.description or '', + 'state': r.state or 'draft', + 'approve_date': str(r.approve_date) if r.approve_date else None, + } + + +class StudentLeaveController(http.Controller): + + @http.route('/api/student-leaves', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_leaves(self, **kw): + try: + M = request.env['encoach.student.leave'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit, order='create_date desc') + items = [_ser_leave(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_leave(self, **kw): + try: + body = _get_json_body() + vals = { + 'start_date': body.get('start_date'), + 'end_date': body.get('end_date'), + } + if body.get('student_id'): + vals['student_id'] = int(body['student_id']) + if body.get('leave_type'): + vals['leave_type_id'] = int(body['leave_type']) + if body.get('leave_type_id'): + vals['leave_type_id'] = int(body['leave_type_id']) + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.student.leave'].sudo().create(vals) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('start_date', 'end_date', 'description'): + if k in body: + vals[k] = body[k] + if 'leave_type' in body: + vals['leave_type_id'] = int(body['leave_type']) + if vals: + rec.write(vals) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves//approve', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def approve_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + from datetime import date + rec.write({'state': 'approve', 'approve_date': str(date.today()), 'approved_by': request.env.uid}) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leaves//reject', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def reject_leave(self, lid, **kw): + try: + rec = request.env['encoach.student.leave'].sudo().browse(lid) + if not rec.exists(): + return _error_response('Not found', 404) + rec.write({'state': 'refuse'}) + return _json_response(_ser_leave(rec)) + except Exception as e: + return _error_response(str(e), 500) + + # ── Leave Types ────────────────────────────────────────────────── + + @http.route('/api/student-leave-types', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_leave_types(self, **kw): + try: + M = request.env['encoach.student.leave.type'].sudo() + offset, limit, page = _paginate(kw) + total = M.search_count([]) + recs = M.search([], offset=offset, limit=limit) + items = [{'id': r.id, 'name': r.name} for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/student-leave-types', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_leave_type(self, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.student.leave.type'].sudo().create({'name': body.get('name', '')}) + return _json_response({'id': rec.id, 'name': rec.name}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/student_progress.py b/custom_addons/encoach_lms_api/controllers/student_progress.py new file mode 100644 index 0000000..ba62342 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/student_progress.py @@ -0,0 +1,215 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +class StudentProgressController(http.Controller): + """ + Institutional "Student Progress" view. + + Aggregates per-student totals across the key OpenEduCat operational + records an admin cares about at a glance: + + - total_attendance → number of op.attendance.line rows (i.e. sessions + attended or at least registered for) + - total_attendance_present → subset where present=True (best-effort, some + OpenEduCat variants store this differently) + - total_assignment → number of op.assignment.sub.line rows + - total_marksheet_line → number of op.marksheet.line rows + - total_admissions → number of op.admission rows (if the student was + admitted through the admission workflow) + + The endpoint returns one row per op.student. Search (?q=) and batch + (?batch_id=) / course (?course_id=) filters are supported so the page + stays usable at scale. + """ + + @http.route('/api/student-progress', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_progress(self, **kw): + try: + env = request.env + Student = env['op.student'].sudo() if 'op.student' in env else None + if Student is None: + return _json_response({'items': [], 'data': [], 'total': 0, 'page': 1, 'size': 0}) + + offset, limit, page = _paginate(kw) + domain = [('active', '=', True)] if 'active' in Student._fields else [] + q = (kw.get('q') or '').strip() + if q: + domain += ['|', + ('partner_id.name', 'ilike', q), + ('gr_no', 'ilike', q)] + batch_id = kw.get('batch_id') + if batch_id: + try: + bid = int(batch_id) + if 'course_detail_ids' in Student._fields: + domain.append(('course_detail_ids.batch_id', '=', bid)) + except Exception: + pass + course_id = kw.get('course_id') + if course_id: + try: + cid = int(course_id) + if 'course_detail_ids' in Student._fields: + domain.append(('course_detail_ids.course_id', '=', cid)) + except Exception: + pass + + total = Student.search_count(domain) + students = Student.search(domain, offset=offset, limit=limit, order='id asc') + + AttLine = env['op.attendance.line'].sudo() if 'op.attendance.line' in env else None + AsgSub = env['op.assignment.sub.line'].sudo() if 'op.assignment.sub.line' in env else None + MsLine = env['op.marksheet.line'].sudo() if 'op.marksheet.line' in env else None + Admission = env['op.admission'].sudo() if 'op.admission' in env else None + + items = [] + for s in students: + sid = s.id + partner = s.partner_id if 'partner_id' in s._fields else False + student_name = partner.name if partner else (s.display_name or '') + + total_attendance = 0 + total_attendance_present = 0 + if AttLine is not None: + try: + total_attendance = AttLine.search_count([('student_id', '=', sid)]) + if 'present' in AttLine._fields: + total_attendance_present = AttLine.search_count( + [('student_id', '=', sid), ('present', '=', True)] + ) + elif 'attendance' in AttLine._fields: + total_attendance_present = AttLine.search_count( + [('student_id', '=', sid), ('attendance', '=', True)] + ) + except Exception: + pass + + total_assignment = 0 + if AsgSub is not None: + try: + total_assignment = AsgSub.search_count([('student_id', '=', sid)]) + except Exception: + pass + + total_marksheet_line = 0 + avg_marks = None + if MsLine is not None: + try: + lines = MsLine.search([('student_id', '=', sid)]) + total_marksheet_line = len(lines) + if lines and 'marks' in MsLine._fields: + marks = [float(l.marks or 0) for l in lines] + if marks: + avg_marks = round(sum(marks) / len(marks), 2) + except Exception: + pass + + total_admissions = 0 + if Admission is not None: + try: + total_admissions = Admission.search_count([('student_id', '=', sid)]) + except Exception: + pass + + attendance_rate = None + if total_attendance: + attendance_rate = round( + (total_attendance_present / total_attendance) * 100, 1 + ) + + items.append({ + 'id': sid, + 'student_id': sid, + 'student_name': student_name, + 'gr_no': getattr(s, 'gr_no', '') or '', + 'total_attendance': total_attendance, + 'total_attendance_present': total_attendance_present, + 'attendance_rate': attendance_rate, + 'total_assignment': total_assignment, + 'total_marksheet_line': total_marksheet_line, + 'avg_marks': avg_marks, + 'total_admissions': total_admissions, + }) + + return _json_response({ + 'items': items, + 'data': items, + 'total': total, + 'page': page, + 'size': limit, + }) + except Exception as e: + _logger.exception('list_progress') + return _error_response(str(e), 500) + + @http.route('/api/student-progress/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_progress(self, sid, **kw): + """Detailed per-student breakdown (lines, not just totals).""" + try: + env = request.env + if 'op.student' not in env: + return _error_response('op.student model not installed', 404) + student = env['op.student'].sudo().browse(sid) + if not student.exists(): + return _error_response('Student not found', 404) + + partner = student.partner_id if 'partner_id' in student._fields else False + out = { + 'id': student.id, + 'student_name': partner.name if partner else (student.display_name or ''), + 'gr_no': getattr(student, 'gr_no', '') or '', + 'attendance': [], + 'assignments': [], + 'marksheets': [], + } + + if 'op.attendance.line' in env: + AL = env['op.attendance.line'].sudo() + lines = AL.search([('student_id', '=', sid)], limit=100, order='id desc') + for l in lines: + present = None + if 'present' in AL._fields: + present = bool(l.present) + elif 'attendance' in AL._fields: + present = bool(l.attendance) + out['attendance'].append({ + 'id': l.id, + 'sheet': l.attendance_id.display_name if 'attendance_id' in l._fields and l.attendance_id else '', + 'present': present, + }) + + if 'op.assignment.sub.line' in env: + AS = env['op.assignment.sub.line'].sudo() + lines = AS.search([('student_id', '=', sid)], limit=100, order='id desc') + for l in lines: + out['assignments'].append({ + 'id': l.id, + 'assignment': l.assignment_id.display_name if 'assignment_id' in l._fields and l.assignment_id else '', + 'marks': float(getattr(l, 'marks', 0) or 0), + 'state': getattr(l, 'state', '') or '', + }) + + if 'op.marksheet.line' in env: + ML = env['op.marksheet.line'].sudo() + lines = ML.search([('student_id', '=', sid)], limit=100, order='id desc') + for l in lines: + out['marksheets'].append({ + 'id': l.id, + 'marksheet': l.marksheet_reg_id.display_name if 'marksheet_reg_id' in l._fields and l.marksheet_reg_id else '', + 'marks': float(getattr(l, 'marks', 0) or 0), + 'grade': getattr(l, 'grade', '') or '', + }) + + return _json_response(out) + except Exception as e: + _logger.exception('get_progress') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/tickets.py b/custom_addons/encoach_lms_api/controllers/tickets.py new file mode 100644 index 0000000..b1b5b4b --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/tickets.py @@ -0,0 +1,176 @@ +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 _ser_ticket(t): + return { + 'id': t.id, + 'subject': t.subject or '', + 'description': t.description or '', + 'type': t.type or 'support', + 'status': t.status or 'open', + 'source': t.source or 'platform', + 'page_context': t.page_context or '', + 'reporter_id': t.reporter_id.id if t.reporter_id else None, + 'reporter_name': t.reporter_id.name if t.reporter_id else '', + 'assignee_id': t.assignee_id.id if t.assignee_id else None, + 'assignee_name': t.assignee_id.name if t.assignee_id else '', + 'entity_id': t.entity_id.id if t.entity_id else None, + 'entity_name': t.entity_id.name if t.entity_id else '', + 'created_at': t.create_date.isoformat() if t.create_date else '', + 'resolved_at': t.resolved_at.isoformat() if t.resolved_at else '', + } + + +class TicketsController(http.Controller): + + @http.route('/api/tickets', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_tickets(self, **kw): + try: + Ticket = request.env['encoach.ticket'].sudo() + domain = [] + status = kw.get('status') + if status and status != 'all': + domain.append(('status', '=', status)) + t = kw.get('type') + if t and t != 'all': + domain.append(('type', '=', t)) + src = kw.get('source') + if src and src != 'all': + domain.append(('source', '=', src)) + assignee = kw.get('assignee_id') + if assignee: + try: + domain.append(('assignee_id', '=', int(assignee))) + except (TypeError, ValueError): + pass + search = kw.get('search') or kw.get('q') + if search: + domain.append('|') + domain.append(('subject', 'ilike', search)) + domain.append(('description', 'ilike', search)) + + try: + size = min(int(kw.get('size') or 50), 200) + except (TypeError, ValueError): + size = 50 + try: + page = max(int(kw.get('page') or 1), 1) + except (TypeError, ValueError): + page = 1 + + total = Ticket.search_count(domain) + recs = Ticket.search(domain, limit=size, offset=(page - 1) * size) + items = [_ser_ticket(t) for t in recs] + return _json_response({ + 'data': items, + 'items': items, + 'total': total, + 'page': page, + 'size': size, + }) + except Exception as e: + _logger.exception('list_tickets') + return _error_response(str(e), 500) + + @http.route('/api/tickets', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_ticket(self, **kw): + try: + body = _get_json_body() + if not body.get('subject'): + return _error_response('subject is required', 400) + vals = { + 'subject': body['subject'], + 'description': body.get('description') or '', + 'type': body.get('type') or 'support', + 'status': body.get('status') or 'open', + 'source': body.get('source') or 'platform', + 'page_context': body.get('page_context') or '', + } + if body.get('assignee_id'): + vals['assignee_id'] = int(body['assignee_id']) + if body.get('entity_id'): + vals['entity_id'] = int(body['entity_id']) + # reporter defaults to current user via the model default. + rec = request.env['encoach.ticket'].sudo().create(vals) + return _json_response(_ser_ticket(rec)) + except Exception as e: + _logger.exception('create_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def get_ticket(self, tid, **kw): + try: + rec = request.env['encoach.ticket'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response(_ser_ticket(rec)) + except Exception as e: + _logger.exception('get_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_ticket(self, tid, **kw): + try: + from odoo import fields as odoo_fields + body = _get_json_body() + rec = request.env['encoach.ticket'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + for k in ('subject', 'description', 'type', 'status', 'source', 'page_context'): + if k in body: + vals[k] = body[k] + if 'assignee_id' in body: + vals['assignee_id'] = int(body['assignee_id']) if body['assignee_id'] else False + if 'entity_id' in body: + vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False + # Auto-stamp resolved_at when transitioning to resolved/closed. + if vals.get('status') in ('resolved', 'closed') and not rec.resolved_at: + vals['resolved_at'] = odoo_fields.Datetime.now() + if vals: + rec.write(vals) + return _json_response(_ser_ticket(rec)) + except Exception as e: + _logger.exception('update_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_ticket(self, tid, **kw): + try: + rec = request.env['encoach.ticket'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_ticket') + return _error_response(str(e), 500) + + @http.route('/api/tickets/assignedToUser', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def assigned_to_user(self, **kw): + try: + uid = request.env.user.id + recs = request.env['encoach.ticket'].sudo().search([ + ('assignee_id', '=', uid), + ], limit=100) + items = [_ser_ticket(t) for t in recs] + return _json_response({'data': items, 'items': items, 'total': len(items)}) + except Exception as e: + _logger.exception('assigned_to_user') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/timetable.py b/custom_addons/encoach_lms_api/controllers/timetable.py new file mode 100644 index 0000000..a898255 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/timetable.py @@ -0,0 +1,75 @@ +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 _ser_session(s): + timing = s.timing_id if hasattr(s, 'timing_id') and s.timing_id else None + return { + 'id': s.id, + 'course_id': s.course_id.id if hasattr(s, 'course_id') and s.course_id else 0, + 'course_name': s.course_id.name if hasattr(s, 'course_id') and s.course_id else '', + 'teacher_name': s.faculty_id.partner_id.name if hasattr(s, 'faculty_id') and s.faculty_id and s.faculty_id.partner_id else '', + 'batch_name': s.batch_id.name if hasattr(s, 'batch_id') and s.batch_id else '', + 'day': getattr(s, 'day', '') or '', + 'start_time': str(timing.hour) + ':' + str(int(timing.minute)).zfill(2) if timing and hasattr(timing, 'hour') else getattr(s, 'start_datetime', '') or '', + 'end_time': '', + 'room': getattr(s, 'classroom_id', False) and s.classroom_id.name or '', + } + + +class TimetableController(http.Controller): + + @http.route('/api/timetable', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_timetable(self, **kw): + try: + M = request.env['op.session'].sudo() + domain = [] + if kw.get('course_id'): + domain.append(('course_id', '=', int(kw['course_id']))) + if kw.get('batch_id'): + domain.append(('batch_id', '=', int(kw['batch_id']))) + recs = M.search(domain, limit=200, order='id desc') + data = [_ser_session(r) for r in recs] + return _json_response({'data': data}) + except Exception as e: + _logger.exception('list_timetable') + return _error_response(str(e), 500) + + @http.route('/api/timetable', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_timetable(self, **kw): + try: + body = _get_json_body() + vals = {} + if body.get('course_id'): + vals['course_id'] = int(body['course_id']) + if body.get('batch_id'): + vals['batch_id'] = int(body['batch_id']) + if body.get('faculty_id'): + vals['faculty_id'] = int(body['faculty_id']) + if body.get('subject_id'): + vals['subject_id'] = int(body['subject_id']) + if body.get('day'): + vals['day'] = body['day'] + rec = request.env['op.session'].sudo().create(vals) + return _json_response(_ser_session(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/timetable/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_timetable(self, sid, **kw): + try: + rec = request.env['op.session'].sudo().browse(sid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/training.py b/custom_addons/encoach_lms_api/controllers/training.py new file mode 100644 index 0000000..8523f09 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/training.py @@ -0,0 +1,372 @@ +"""Training endpoints: vocabulary + grammar library + per-user progress.""" +import logging +from odoo import http, fields as odoo_fields +from odoo.exceptions import ValidationError, UserError +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 _user_error_status(e): + """Translate Odoo validation errors into HTTP 400 instead of 500.""" + return 400 if isinstance(e, (ValidationError, UserError)) else 500 + + +# ── Serializers ───────────────────────────────────────────────────────── +def _ser_vocab(v, progress=None): + return { + 'id': v.id, + 'word': v.word or '', + 'meaning': v.meaning or '', + 'example_sentence': v.example_sentence or '', + 'level': v.level or 'B1', + 'part_of_speech': v.part_of_speech or 'noun', + 'category': v.category or 'general', + 'active': v.active, + 'learners_count': v.learners_count or 0, + 'completion_count': v.completion_count or 0, + 'completed': bool(progress and progress.completed), + 'mastery': (progress.mastery if progress else 'learning'), + 'last_reviewed': (progress.last_reviewed.isoformat() if progress and progress.last_reviewed else ''), + } + + +def _ser_rule(r, progress=None): + return { + 'id': r.id, + 'name': r.name or '', + 'description': r.description or '', + 'example': r.example or '', + 'level': r.level or 'B1', + 'category': r.category or 'general', + 'active': r.active, + 'learners_count': r.learners_count or 0, + 'completion_count': r.completion_count or 0, + 'completed': bool(progress and progress.completed), + 'last_reviewed': (progress.last_reviewed.isoformat() if progress and progress.last_reviewed else ''), + } + + +def _domain_from_kw(kw, text_fields): + domain = [] + if kw.get('level') and kw['level'] != 'all': + domain.append(('level', '=', kw['level'])) + if kw.get('category') and kw['category'] != 'all': + domain.append(('category', '=', kw['category'])) + active = kw.get('active') + if active in ('true', 'false'): + domain.append(('active', '=', active == 'true')) + search = kw.get('search') or kw.get('q') + if search and text_fields: + terms = [(f, 'ilike', search) for f in text_fields] + if len(terms) > 1: + domain.extend(['|'] * (len(terms) - 1)) + domain.extend(terms) + return domain + + +def _pager(kw): + try: + size = min(int(kw.get('size') or 100), 500) + except (TypeError, ValueError): + size = 100 + try: + page = max(int(kw.get('page') or 1), 1) + except (TypeError, ValueError): + page = 1 + return size, page + + +# ── Vocabulary ────────────────────────────────────────────────────────── +class VocabularyController(http.Controller): + + @http.route('/api/training/vocabulary', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_vocab(self, **kw): + try: + Vocab = request.env['encoach.vocab.item'].sudo() + domain = _domain_from_kw(kw, ['word', 'meaning', 'category']) + size, page = _pager(kw) + total = Vocab.search_count(domain) + recs = Vocab.search(domain, limit=size, offset=(page - 1) * size) + + Progress = request.env['encoach.vocab.progress'].sudo() + uid = request.env.user.id + prog_map = { + p.vocab_id.id: p for p in Progress.search([ + ('user_id', '=', uid), + ('vocab_id', 'in', recs.ids), + ]) + } if recs else {} + + items = [_ser_vocab(v, prog_map.get(v.id)) for v in recs] + completed = sum(1 for i in items if i['completed']) + return _json_response({ + 'data': items, 'items': items, 'total': total, + 'summary': { + 'total': total, + 'completed': completed, + 'remaining': total - completed, + 'completion_rate': round((completed / total) * 100, 1) if total else 0.0, + }, + 'page': page, 'size': size, + }) + except Exception as e: + _logger.exception('list_vocab') + return _error_response(str(e), 500) + + @http.route('/api/training/vocabulary', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_vocab(self, **kw): + try: + body = _get_json_body() + if not body.get('word') or not body.get('meaning'): + return _error_response('word and meaning are required', 400) + vals = { + 'word': body['word'].strip(), + 'meaning': body['meaning'].strip(), + 'example_sentence': body.get('example_sentence') or '', + 'level': body.get('level') or 'B1', + 'part_of_speech': body.get('part_of_speech') or 'noun', + 'category': body.get('category') or 'general', + 'active': body.get('active', True), + } + rec = request.env['encoach.vocab.item'].sudo().create(vals) + return _json_response(_ser_vocab(rec)) + except Exception as e: + _logger.exception('create_vocab') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/vocabulary/', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_vocab(self, vid, **kw): + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if not rec.exists(): + return _error_response('Not found', 404) + prog = request.env['encoach.vocab.progress'].sudo().search([ + ('user_id', '=', request.env.user.id), + ('vocab_id', '=', rec.id), + ], limit=1) + return _json_response(_ser_vocab(rec, prog)) + + @http.route('/api/training/vocabulary/', type='http', + auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_vocab(self, vid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + for k in ('word', 'meaning', 'example_sentence', 'level', + 'part_of_speech', 'category'): + if k in body: + vals[k] = body[k] + if 'active' in body: + vals['active'] = bool(body['active']) + if vals: + rec.write(vals) + return _json_response(_ser_vocab(rec)) + except Exception as e: + _logger.exception('update_vocab') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/vocabulary/', type='http', + auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_vocab(self, vid, **kw): + try: + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_vocab') + return _error_response(str(e), 500) + + @http.route('/api/training/vocabulary//progress', + type='http', auth='public', + methods=['POST', 'PATCH'], csrf=False) + @jwt_required + def set_vocab_progress(self, vid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.vocab.item'].sudo().browse(vid) + if not rec.exists(): + return _error_response('Not found', 404) + Progress = request.env['encoach.vocab.progress'].sudo() + uid = request.env.user.id + prog = Progress.search([ + ('user_id', '=', uid), + ('vocab_id', '=', rec.id), + ], limit=1) + vals = { + 'user_id': uid, + 'vocab_id': rec.id, + 'last_reviewed': odoo_fields.Datetime.now(), + } + if 'completed' in body: + vals['completed'] = bool(body['completed']) + if body.get('mastery') in ('learning', 'familiar', 'mastered'): + vals['mastery'] = body['mastery'] + if prog: + vals['review_count'] = (prog.review_count or 0) + 1 + prog.write(vals) + else: + vals['review_count'] = 1 + prog = Progress.create(vals) + return _json_response(_ser_vocab(rec, prog)) + except Exception as e: + _logger.exception('set_vocab_progress') + return _error_response(str(e), 500) + + +# ── Grammar ───────────────────────────────────────────────────────────── +class GrammarController(http.Controller): + + @http.route('/api/training/grammar', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_grammar(self, **kw): + try: + Rule = request.env['encoach.grammar.rule'].sudo() + domain = _domain_from_kw(kw, ['name', 'description', 'category']) + size, page = _pager(kw) + total = Rule.search_count(domain) + recs = Rule.search(domain, limit=size, offset=(page - 1) * size) + + Progress = request.env['encoach.grammar.progress'].sudo() + uid = request.env.user.id + prog_map = { + p.rule_id.id: p for p in Progress.search([ + ('user_id', '=', uid), + ('rule_id', 'in', recs.ids), + ]) + } if recs else {} + + items = [_ser_rule(r, prog_map.get(r.id)) for r in recs] + completed = sum(1 for i in items if i['completed']) + return _json_response({ + 'data': items, 'items': items, 'total': total, + 'summary': { + 'total': total, + 'completed': completed, + 'remaining': total - completed, + 'completion_rate': round((completed / total) * 100, 1) if total else 0.0, + }, + 'page': page, 'size': size, + }) + except Exception as e: + _logger.exception('list_grammar') + return _error_response(str(e), 500) + + @http.route('/api/training/grammar', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_rule(self, **kw): + try: + body = _get_json_body() + if not body.get('name') or not body.get('description'): + return _error_response('name and description are required', 400) + vals = { + 'name': body['name'].strip(), + 'description': body['description'].strip(), + 'example': body.get('example') or '', + 'level': body.get('level') or 'B1', + 'category': body.get('category') or 'general', + 'active': body.get('active', True), + } + rec = request.env['encoach.grammar.rule'].sudo().create(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + _logger.exception('create_rule') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/grammar/', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_rule(self, rid, **kw): + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + prog = request.env['encoach.grammar.progress'].sudo().search([ + ('user_id', '=', request.env.user.id), + ('rule_id', '=', rec.id), + ], limit=1) + return _json_response(_ser_rule(rec, prog)) + + @http.route('/api/training/grammar/', type='http', + auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_rule(self, rid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + for k in ('name', 'description', 'example', 'level', 'category'): + if k in body: + vals[k] = body[k] + if 'active' in body: + vals['active'] = bool(body['active']) + if vals: + rec.write(vals) + return _json_response(_ser_rule(rec)) + except Exception as e: + _logger.exception('update_rule') + return _error_response(str(e), _user_error_status(e)) + + @http.route('/api/training/grammar/', type='http', + auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_rule(self, rid, **kw): + try: + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + _logger.exception('delete_rule') + return _error_response(str(e), 500) + + @http.route('/api/training/grammar//progress', + type='http', auth='public', + methods=['POST', 'PATCH'], csrf=False) + @jwt_required + def set_rule_progress(self, rid, **kw): + try: + body = _get_json_body() + rec = request.env['encoach.grammar.rule'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + Progress = request.env['encoach.grammar.progress'].sudo() + uid = request.env.user.id + prog = Progress.search([ + ('user_id', '=', uid), + ('rule_id', '=', rec.id), + ], limit=1) + vals = { + 'user_id': uid, + 'rule_id': rec.id, + 'last_reviewed': odoo_fields.Datetime.now(), + } + if 'completed' in body: + vals['completed'] = bool(body['completed']) + if prog: + vals['review_count'] = (prog.review_count or 0) + 1 + prog.write(vals) + else: + vals['review_count'] = 1 + prog = Progress.create(vals) + return _json_response(_ser_rule(rec, prog)) + except Exception as e: + _logger.exception('set_rule_progress') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/controllers/users_roles.py b/custom_addons/encoach_lms_api/controllers/users_roles.py new file mode 100644 index 0000000..cfa4919 --- /dev/null +++ b/custom_addons/encoach_lms_api/controllers/users_roles.py @@ -0,0 +1,542 @@ +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, _paginate, +) + +_logger = logging.getLogger(__name__) + + +def _detect_user_type(u): + """Detect effective user type from user_type field, groups, and linked records.""" + ut = getattr(u, 'user_type', '') or '' + if ut == 'admin': + return 'admin' + if ut == 'teacher': + return 'teacher' + if u.has_group('base.group_system') or u.has_group('base.group_erp_manager'): + return 'admin' + is_faculty = bool(u.env['op.faculty'].sudo().search([('user_id', '=', u.id)], limit=1)) + if is_faculty: + return 'teacher' + is_student = bool(u.env['op.student'].sudo().search([('user_id', '=', u.id)], limit=1)) + if is_student: + return 'student' + if u.has_group('base.group_portal'): + return 'student' + return ut or 'user' + + +def _ser_user(u): + role_ids = u.role_ids.ids if hasattr(u, 'role_ids') else [] + roles = [{'id': r.id, 'name': r.name or ''} for r in u.role_ids] if hasattr(u, 'role_ids') else [] + return { + 'id': u.id, + 'name': u.name or '', + 'email': u.email or u.login or '', + 'login': u.login or '', + 'user_type': _detect_user_type(u), + 'is_verified': True, + 'active': u.active, + 'phone': u.phone or '', + 'gender': getattr(u, 'gender', '') or '', + 'role_ids': role_ids, + 'roles': roles, + 'effective_permission_count': len(u.get_all_permissions()) if hasattr(u, 'get_all_permissions') else 0, + } + + +def _ser_role(r): + """Full role serialization for the frontend Role type.""" + entity = r.entity_id + return { + 'id': r.id, + 'name': r.name or '', + 'code': getattr(r, 'code', '') or '', + 'description': getattr(r, 'description', '') or '', + 'entity_id': entity.id if entity else None, + 'entity_name': entity.name if entity else '', + 'permission_ids': r.permission_ids.ids, + 'permissions': [ + { + 'id': p.id, + 'name': getattr(p, 'name', '') or p.code or '', + 'code': p.code or '', + 'description': getattr(p, 'description', '') or '', + 'category': getattr(p, 'topic', '') or '', + } + for p in r.permission_ids + ], + 'user_count': getattr(r, 'user_count', 0) or len(r.user_ids) if hasattr(r, 'user_ids') else 0, + } + + +def _ser_perm(p): + return { + 'id': p.id, + 'name': getattr(p, 'name', '') or p.code or '', + 'code': p.code or '', + 'description': getattr(p, 'description', '') or '', + 'category': getattr(p, 'topic', '') or '', + } + + +class UsersRolesController(http.Controller): + + # ── Users ──────────────────────────────────────────────────────── + + @http.route('/api/users/list', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_users(self, **kw): + try: + M = request.env['res.users'].sudo() + domain = [('share', '=', False)] + if kw.get('type'): + domain.append(('user_type', '=', kw['type'])) + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_user(r) for r in recs] + return _json_response({'items': items, 'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_users') + return _error_response(str(e), 500) + + @http.route('/api/users/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_user(self, uid_param, **kw): + try: + rec = request.env['res.users'].sudo().browse(uid_param) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _ser_user(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/users/update', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_user(self, **kw): + try: + body = _get_json_body() + uid_val = body.get('id') + if not uid_val: + return _error_response('Missing id', 400) + rec = request.env['res.users'].sudo().browse(int(uid_val)) + if not rec.exists(): + return _error_response('Not found', 404) + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'email' in body: + vals['email'] = body['email'] + if 'phone' in body: + vals['phone'] = body['phone'] + if vals: + rec.write(vals) + return _json_response({'data': _ser_user(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/users/create', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_user(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'login': body.get('email', body.get('login', '')), + 'email': body.get('email', ''), + 'password': body.get('password', 'user123'), + } + rec = request.env['res.users'].sudo().create(vals) + return _json_response({'data': _ser_user(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/batch_users', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_batch_users(self, **kw): + try: + body = _get_json_body() + users = body.get('users', []) + created = [] + for u in users: + vals = { + 'name': u.get('name', ''), + 'login': u.get('email', u.get('login', '')), + 'email': u.get('email', ''), + 'password': u.get('password', 'user123'), + } + rec = request.env['res.users'].sudo().create(vals) + created.append(rec.id) + return _json_response({'success': True, 'created_ids': created}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Roles ──────────────────────────────────────────────────────── + + @http.route('/api/roles', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_roles(self, **kw): + try: + M = request.env['encoach.role'].sudo() + domain = [] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('entity_id'): + domain.append(('entity_id', '=', int(kw['entity_id']))) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [_ser_role(r) for r in recs] + return _json_response({'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_role(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_role(self, **kw): + try: + body = _get_json_body() + name = body.get('name', '').strip() + if not name: + return _error_response('Name is required', 400) + + entity_id = body.get('entity_id') + if not entity_id: + entity = request.env['encoach.entity'].sudo().search([], limit=1) + entity_id = entity.id if entity else False + if not entity_id: + return _error_response('No entity available for role', 400) + + vals = { + 'name': name, + 'entity_id': int(entity_id), + } + if body.get('code'): + vals['code'] = body['code'] + if body.get('description'): + vals['description'] = body['description'] + if body.get('permission_ids'): + vals['permission_ids'] = [(6, 0, [int(p) for p in body['permission_ids']])] + + rec = request.env['encoach.role'].sudo().create(vals) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_role(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'code' in body: + vals['code'] = body['code'] + if 'description' in body: + vals['description'] = body['description'] + if vals: + rec.write(vals) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_role(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/roles//permissions', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_role_permissions(self, rid, **kw): + try: + rec = request.env['encoach.role'].sudo().browse(rid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + perm_ids = body.get('permission_ids', []) + rec.write({'permission_ids': [(6, 0, [int(p) for p in perm_ids])]}) + return _json_response({'data': _ser_role(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Permissions ────────────────────────────────────────────────── + + @http.route('/api/permissions', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_permissions(self, **kw): + try: + M = request.env['encoach.permission'].sudo() + domain = [] + if kw.get('category'): + domain.append(('topic', '=', kw['category'])) + if kw.get('search'): + domain.append(('code', 'ilike', kw['search'])) + recs = M.search(domain, limit=500, order='topic, code') + items = [_ser_perm(r) for r in recs] + return _json_response({'data': items, 'total': len(items)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/permissions', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def create_permission(self, **kw): + try: + body = _get_json_body() + code = body.get('code', body.get('name', '')).strip() + if not code: + return _error_response('Code is required', 400) + vals = {'code': code} + if body.get('name'): + vals['name'] = body['name'] + if body.get('description'): + vals['description'] = body['description'] + if body.get('topic') or body.get('category'): + vals['topic'] = body.get('topic') or body.get('category', '') + rec = request.env['encoach.permission'].sudo().create(vals) + return _json_response({'data': _ser_perm(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/permissions/', type='http', auth='public', methods=['DELETE'], csrf=False) + @jwt_required + def delete_permission(self, pid, **kw): + try: + rec = request.env['encoach.permission'].sudo().browse(pid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ── Authority Matrix ───────────────────────────────────────────── + + @http.route('/api/authority-matrix', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_authority_matrix(self, **kw): + try: + roles = request.env['encoach.role'].sudo().search([]) + perms = request.env['encoach.permission'].sudo().search([], order='topic, code') + + categories = {} + for p in perms: + cat = p.topic or 'General' + categories.setdefault(cat, []).append(_ser_perm(p)) + + role_list = [] + for r in roles: + role_list.append({ + 'id': r.id, + 'name': r.name or '', + 'description': getattr(r, 'description', '') or '', + 'granted_permission_ids': r.permission_ids.ids, + }) + + return _json_response({ + 'roles': role_list, + 'permissions': [_ser_perm(p) for p in perms], + 'categories': categories, + 'matrix': {str(r.id): r.permission_ids.ids for r in roles}, + 'total_roles': len(roles), + 'total_permissions': len(perms), + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/authority-matrix/toggle', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def toggle_matrix(self, **kw): + try: + body = _get_json_body() + role = request.env['encoach.role'].sudo().browse(int(body.get('role_id', 0))) + perm_id = int(body.get('permission_id', 0)) + if not role.exists(): + return _error_response('Role not found', 404) + current = role.permission_ids.ids + if perm_id in current: + role.write({'permission_ids': [(3, perm_id)]}) + granted = False + else: + role.write({'permission_ids': [(4, perm_id)]}) + granted = True + return _json_response({'role_id': role.id, 'permission_id': perm_id, 'granted': granted}) + except Exception as e: + return _error_response(str(e), 500) + + # ── User-Role Assignment ───────────────────────────────────────── + + @http.route('/api/users/with-roles', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def list_users_with_roles(self, **kw): + try: + M = request.env['res.users'].sudo() + domain = [('share', '=', False)] + if kw.get('search'): + domain.append(('name', 'ilike', kw['search'])) + if kw.get('role_id'): + domain.append(('role_ids', 'in', [int(kw['role_id'])])) + offset, limit, page = _paginate(kw) + total = M.search_count(domain) + recs = M.search(domain, offset=offset, limit=limit, order='id desc') + items = [] + for u in recs: + role_ids = u.role_ids.ids if hasattr(u, 'role_ids') else [] + all_perms = u.get_all_permissions() if hasattr(u, 'get_all_permissions') else u.role_ids.mapped('permission_ids') + items.append({ + 'id': u.id, + 'name': u.name or '', + 'login': u.login or '', + 'email': u.email or u.login or '', + 'active': u.active, + 'role_ids': role_ids, + 'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids], + 'effective_permission_count': len(all_perms), + }) + return _json_response({'data': items, 'total': total, 'page': page, 'size': limit}) + except Exception as e: + _logger.exception('list_users_with_roles') + return _error_response(str(e), 500) + + @http.route('/api/users//roles', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_user_roles(self, uid_param, **kw): + try: + user = request.env['res.users'].sudo().browse(uid_param) + if not user.exists(): + return _error_response('User not found', 404) + + all_roles = request.env['encoach.role'].sudo().search([]) + assigned_ids = user.role_ids.ids if hasattr(user, 'role_ids') else [] + + all_perms = user.get_all_permissions() if hasattr(user, 'get_all_permissions') else request.env['encoach.permission'] + + roles_detail = [] + for r in all_roles: + roles_detail.append({ + 'id': r.id, + 'name': r.name or '', + 'description': getattr(r, 'description', '') or '', + 'assigned': r.id in assigned_ids, + 'permission_count': len(r.permission_ids), + }) + + return _json_response({ + 'user': { + 'id': user.id, + 'name': user.name or '', + 'login': user.login or '', + 'email': user.email or '', + }, + 'roles': roles_detail, + 'effective_permissions': [_ser_perm(p) for p in all_perms], + }) + except Exception as e: + _logger.exception('get_user_roles') + return _error_response(str(e), 500) + + @http.route('/api/users//roles', type='http', auth='public', methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_user_roles(self, uid_param, **kw): + try: + user = request.env['res.users'].sudo().browse(uid_param) + if not user.exists(): + return _error_response('User not found', 404) + body = _get_json_body() + role_ids = body.get('role_ids', []) + user.write({'role_ids': [(6, 0, [int(r) for r in role_ids])]}) + return _json_response({ + 'success': True, + 'user_id': user.id, + 'role_ids': user.role_ids.ids, + }) + except Exception as e: + _logger.exception('update_user_roles') + return _error_response(str(e), 500) + + @http.route('/api/users//roles/toggle', type='http', auth='public', methods=['POST'], csrf=False) + @jwt_required + def toggle_user_role(self, uid_param, **kw): + try: + user = request.env['res.users'].sudo().browse(uid_param) + if not user.exists(): + return _error_response('User not found', 404) + body = _get_json_body() + role_id = int(body.get('role_id', 0)) + if not role_id: + return _error_response('Missing role_id', 400) + + current = user.role_ids.ids if hasattr(user, 'role_ids') else [] + if role_id in current: + user.write({'role_ids': [(3, role_id)]}) + assigned = False + else: + user.write({'role_ids': [(4, role_id)]}) + assigned = True + return _json_response({ + 'user_id': user.id, + 'role_id': role_id, + 'assigned': assigned, + }) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/user-authority-matrix', type='http', auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_user_authority_matrix(self, **kw): + try: + users = request.env['res.users'].sudo().search([ + ('share', '=', False), + ], order='name', limit=200) + perms = request.env['encoach.permission'].sudo().search([], order='topic, code') + + categories = {} + for p in perms: + cat = p.topic or 'General' + categories.setdefault(cat, []).append(_ser_perm(p)) + + user_list = [] + for u in users: + all_perms = u.get_all_permissions() if hasattr(u, 'get_all_permissions') else u.role_ids.mapped('permission_ids') + user_list.append({ + 'id': u.id, + 'name': u.name or '', + 'login': u.login or '', + 'roles': [{'id': r.id, 'name': r.name} for r in u.role_ids], + 'granted_permission_ids': all_perms.ids, + }) + + return _json_response({ + 'users': user_list, + 'permissions': [_ser_perm(p) for p in perms], + 'categories': categories, + 'total_users': len(user_list), + 'total_permissions': len(perms), + }) + except Exception as e: + _logger.exception('get_user_authority_matrix') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_lms_api/models/__init__.py b/custom_addons/encoach_lms_api/models/__init__.py new file mode 100644 index 0000000..34227c3 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/__init__.py @@ -0,0 +1,11 @@ +from . import lesson +from . import student_leave +from . import gradebook +from . import communication +from . import courseware +from . import notification +from . import faq +from . import course_ext +from . import asset +from . import ticket +from . import training diff --git a/custom_addons/encoach_lms_api/models/asset.py b/custom_addons/encoach_lms_api/models/asset.py new file mode 100644 index 0000000..97c6e64 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/asset.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class EncoachAsset(models.Model): + _name = 'encoach.asset' + _description = 'Institutional Asset (lightweight)' + _order = 'id desc' + + name = fields.Char('Name', required=True) + code = fields.Char('Code') + facility_id = fields.Many2one('op.facility', string='Facility') + description = fields.Text('Description') + active = fields.Boolean('Active', default=True) diff --git a/custom_addons/encoach_lms_api/models/communication.py b/custom_addons/encoach_lms_api/models/communication.py new file mode 100644 index 0000000..0b2c977 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/communication.py @@ -0,0 +1,69 @@ +from odoo import models, fields + + +class EncoachDiscussionBoard(models.Model): + _name = 'encoach.discussion.board' + _description = 'Discussion Board' + + name = fields.Char(required=True) + course_id = fields.Many2one('op.course', ondelete='cascade') + batch_id = fields.Many2one('op.batch', ondelete='set null') + chapter_id = fields.Many2one('encoach.course.chapter', ondelete='set null') + is_enabled = fields.Boolean(default=True) + allow_student_posts = fields.Boolean(default=True) + post_ids = fields.One2many('encoach.discussion.post', 'board_id') + post_count = fields.Integer(compute='_compute_post_count', store=True) + + def _compute_post_count(self): + for rec in self: + rec.post_count = len(rec.post_ids) + + +class EncoachDiscussionPost(models.Model): + _name = 'encoach.discussion.post' + _description = 'Discussion Post' + _order = 'create_date desc' + + board_id = fields.Many2one('encoach.discussion.board', required=True, ondelete='cascade') + parent_id = fields.Many2one('encoach.discussion.post', string='Parent', ondelete='cascade') + child_ids = fields.One2many('encoach.discussion.post', 'parent_id', string='Replies') + author_id = fields.Many2one('res.users', required=True, ondelete='cascade') + title = fields.Char() + content = fields.Text(required=True) + is_pinned = fields.Boolean(default=False) + is_resolved = fields.Boolean(default=False) + + +class EncoachAnnouncement(models.Model): + _name = 'encoach.announcement' + _description = 'Announcement' + _order = 'create_date desc' + + title = fields.Char(required=True) + content = fields.Text(required=True) + author_id = fields.Many2one('res.users', required=True, ondelete='cascade') + course_id = fields.Many2one('op.course', ondelete='set null') + batch_id = fields.Many2one('op.batch', ondelete='set null') + priority = fields.Selection([ + ('normal', 'Normal'), + ('important', 'Important'), + ('urgent', 'Urgent'), + ], default='normal') + is_published = fields.Boolean(default=False) + published_at = fields.Datetime() + expires_at = fields.Datetime() + send_email = fields.Boolean(default=False) + + +class EncoachMessage(models.Model): + _name = 'encoach.message' + _description = 'Direct Message' + _order = 'create_date desc' + + sender_id = fields.Many2one('res.users', required=True, ondelete='cascade') + recipient_id = fields.Many2one('res.users', required=True, ondelete='cascade') + subject = fields.Char(required=True) + content = fields.Text(required=True) + is_read = fields.Boolean(default=False) + read_at = fields.Datetime() + send_email_copy = fields.Boolean(default=False) diff --git a/custom_addons/encoach_lms_api/models/course_ext.py b/custom_addons/encoach_lms_api/models/course_ext.py new file mode 100644 index 0000000..4a9be14 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/course_ext.py @@ -0,0 +1,56 @@ +from odoo import models, fields, api + + +class OpCourseExt(models.Model): + _inherit = 'op.course' + + description = fields.Text('Description') + max_capacity = fields.Integer('Max Capacity', default=30) + + encoach_subject_id = fields.Many2one( + 'encoach.subject', string='Taxonomy Subject', ondelete='set null', index=True, + ) + encoach_topic_ids = fields.Many2many( + 'encoach.topic', 'op_course_encoach_topic_rel', + 'course_id', 'topic_id', string='Topics', + ) + learning_objective_ids = fields.Many2many( + 'encoach.learning.objective', 'op_course_learning_obj_rel', + 'course_id', 'objective_id', string='Learning Objectives', + ) + encoach_tag_ids = fields.Many2many( + 'encoach.resource.tag', 'op_course_resource_tag_rel', + 'course_id', 'tag_id', string='Tags', + ) + difficulty_level = fields.Selection([ + ('beginner', 'Beginner'), + ('intermediate', 'Intermediate'), + ('advanced', 'Advanced'), + ]) + cefr_level = fields.Selection([ + ('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'), + ('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'), + ]) + + chapter_ids = fields.One2many('encoach.course.chapter', 'course_id', string='Chapters') + chapter_count = fields.Integer(compute='_compute_chapter_count', store=True) + objective_count = fields.Integer(compute='_compute_objective_count') + resource_count = fields.Integer(compute='_compute_resource_count') + + @api.depends('chapter_ids') + def _compute_chapter_count(self): + for rec in self: + rec.chapter_count = len(rec.chapter_ids) + + def _compute_objective_count(self): + for rec in self: + rec.objective_count = len(rec.learning_objective_ids) + + def _compute_resource_count(self): + Mat = self.env['encoach.chapter.material'].sudo() + for rec in self: + mats = Mat.search([ + ('chapter_id.course_id', '=', rec.id), + ('resource_id', '!=', False), + ]) + rec.resource_count = len(mats.mapped('resource_id')) diff --git a/custom_addons/encoach_lms_api/models/courseware.py b/custom_addons/encoach_lms_api/models/courseware.py new file mode 100644 index 0000000..ae9e4a6 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/courseware.py @@ -0,0 +1,235 @@ +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class EncoachCourseChapter(models.Model): + _name = 'encoach.course.chapter' + _description = 'Course Chapter' + _order = 'sequence, id' + + name = fields.Char(required=True) + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + sequence = fields.Integer(default=10) + description = fields.Text() + start_date = fields.Date() + end_date = fields.Date() + unlock_mode = fields.Selection([ + ('auto_date', 'Automatic by Date'), + ('manual', 'Manual'), + ('prerequisite', 'Prerequisite'), + ], default='manual') + is_unlocked = fields.Boolean(default=True) + topic_id = fields.Many2one('encoach.topic', ondelete='set null') + domain_id = fields.Many2one( + 'encoach.domain', compute='_compute_domain_id', store=True, + ondelete='set null', string='Domain', + ) + learning_objective_ids = fields.Many2many( + 'encoach.learning.objective', 'encoach_chapter_obj_rel', + 'chapter_id', 'objective_id', string='Learning Objectives', + ) + material_ids = fields.One2many('encoach.chapter.material', 'chapter_id') + material_count = fields.Integer(compute='_compute_material_count', store=True) + + @api.depends('topic_id', 'topic_id.domain_id') + def _compute_domain_id(self): + for rec in self: + rec.domain_id = rec.topic_id.domain_id.id if rec.topic_id and rec.topic_id.domain_id else False + + @api.depends('material_ids') + def _compute_material_count(self): + for rec in self: + rec.material_count = len(rec.material_ids) + + +class EncoachChapterMaterial(models.Model): + _name = 'encoach.chapter.material' + _description = 'Chapter Material' + _order = 'sequence, id' + + name = fields.Char(required=True) + chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade') + type = fields.Selection([ + ('pdf', 'PDF'), + ('document', 'Document'), + ('video', 'Video'), + ('audio', 'Audio'), + ('image', 'Image'), + ('link', 'Link'), + ('article', 'Article'), + ], default='pdf') + file_data = fields.Binary(attachment=True) + file_name = fields.Char() + url = fields.Char() + description = fields.Text() + sequence = fields.Integer(default=10) + allow_download = fields.Boolean(default=True) + is_book = fields.Boolean(default=False) + book_chapters = fields.Text() + resource_id = fields.Many2one('encoach.resource', ondelete='set null', + help="Auto-created library resource mirror") + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._vector_index() + records._sync_library_resource() + return records + + def write(self, vals): + res = super().write(vals) + if self.env.context.get('no_sync'): + return res + if any(f in vals for f in ('name', 'description', 'type')): + self._vector_index() + sync_fields = ('name', 'type', 'url', 'file_data') + if any(f in vals for f in sync_fields): + self._sync_library_resource() + return res + + def unlink(self): + self._vector_delete() + resources = self.mapped('resource_id').filtered('exists') + res = super().unlink() + resources.unlink() + return res + + def _sync_library_resource(self): + """Create or update a matching encoach.resource for each material, propagating taxonomy.""" + Res = self.env.get('encoach.resource') + if Res is None: + return + Res = self.env['encoach.resource'].sudo() + type_map = { + 'pdf': 'pdf', 'document': 'document', 'video': 'video', + 'audio': 'pdf', 'image': 'pdf', 'link': 'link', 'article': 'document', + } + for rec in self: + vals = { + 'name': rec.name, + 'type': type_map.get(rec.type, 'document'), + 'url': rec.url or '', + 'review_status': 'approved', + } + if rec.file_data: + vals['file'] = rec.file_data + chapter = rec.chapter_id + if chapter: + if chapter.topic_id: + vals['topic_ids'] = [(4, chapter.topic_id.id)] + if chapter.domain_id: + vals['domain_id'] = chapter.domain_id.id + course = chapter.course_id + if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: + vals['subject_id'] = course.encoach_subject_id.id + if chapter.learning_objective_ids: + vals['learning_objective_ids'] = [(6, 0, chapter.learning_objective_ids.ids)] + if rec.resource_id and rec.resource_id.exists(): + rec.resource_id.write(vals) + else: + new_res = Res.create(vals) + rec.with_context(no_sync=True).write({'resource_id': new_res.id}) + + def _vector_index(self): + """Index material into the vector store for RAG retrieval with full taxonomy context.""" + try: + svc_mod = self.env.get('encoach.embedding') + if svc_mod is None: + return + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(self.env) + for rec in self: + parts = [rec.name or ''] + if rec.description: + parts.append(str(rec.description)[:2000]) + chapter = rec.chapter_id + if chapter: + if chapter.course_id: + parts.append(f"Course: {chapter.course_id.name}") + parts.append(f"Chapter: {chapter.name}") + if chapter.topic_id: + parts.append(f"Topic: {chapter.topic_id.name}") + if chapter.domain_id: + parts.append(f"Domain: {chapter.domain_id.name}") + course = chapter.course_id + if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: + parts.append(f"Subject: {course.encoach_subject_id.name}") + for obj in chapter.learning_objective_ids: + parts.append(f"Objective: {obj.name}") + text = ' '.join(p for p in parts if p).strip() + if text: + metadata = {'type': rec.type or 'pdf'} + if chapter: + metadata['chapter_id'] = chapter.id + metadata['chapter_name'] = chapter.name or '' + if chapter.course_id: + metadata['course_id'] = chapter.course_id.id + metadata['course_name'] = chapter.course_id.name or '' + if chapter.topic_id: + metadata['topic_id'] = chapter.topic_id.id + metadata['topic_name'] = chapter.topic_id.name or '' + if chapter.domain_id: + metadata['domain_id'] = chapter.domain_id.id + metadata['domain_name'] = chapter.domain_id.name or '' + course = chapter.course_id + if course and hasattr(course, 'encoach_subject_id') and course.encoach_subject_id: + metadata['subject_id'] = course.encoach_subject_id.id + metadata['subject_name'] = course.encoach_subject_id.name or '' + if chapter.learning_objective_ids: + metadata['objective_ids'] = chapter.learning_objective_ids.ids + metadata['objective_names'] = chapter.learning_objective_ids.mapped('name') + svc.upsert('material', rec.id, text, metadata) + self.env.cr.commit() + except Exception: + _logger.warning("Failed to vector-index material(s)", exc_info=True) + + def _vector_delete(self): + """Remove material embeddings from the vector store.""" + try: + svc_mod = self.env.get('encoach.embedding') + if svc_mod is None: + return + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(self.env) + for rec in self: + svc.delete('material', rec.id) + except Exception: + _logger.warning("Failed to delete material vector(s)", exc_info=True) + + +class EncoachChapterProgress(models.Model): + _name = 'encoach.chapter.progress' + _description = 'Chapter Progress' + _rec_name = 'chapter_id' + + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + chapter_id = fields.Many2one('encoach.course.chapter', required=True, ondelete='cascade') + status = fields.Selection([ + ('not_started', 'Not Started'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ], default='not_started') + started_at = fields.Datetime() + completed_at = fields.Datetime() + materials_completed = fields.Integer(default=0) + materials_total = fields.Integer(default=0) + + +class EncoachCourseCompletion(models.Model): + _name = 'encoach.course.completion' + _description = 'Course Completion' + _rec_name = 'course_id' + + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + status = fields.Selection([ + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ], default='in_progress') + chapters_total = fields.Integer(default=0) + chapters_completed = fields.Integer(default=0) + progress_percent = fields.Integer(default=0) + completed_at = fields.Datetime() + post_test_available = fields.Boolean(default=False) diff --git a/custom_addons/encoach_lms_api/models/faq.py b/custom_addons/encoach_lms_api/models/faq.py new file mode 100644 index 0000000..def10d8 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/faq.py @@ -0,0 +1,41 @@ +from odoo import models, fields + + +class EncoachFaqCategory(models.Model): + _name = 'encoach.faq.category' + _description = 'FAQ Category' + _order = 'sequence, id' + + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + audience = fields.Selection([ + ('all', 'All'), + ('student', 'Students'), + ('teacher', 'Teachers'), + ('admin', 'Admins'), + ], default='all') + is_published = fields.Boolean(default=True) + item_ids = fields.One2many('encoach.faq.item', 'category_id') + item_count = fields.Integer(compute='_compute_item_count', store=True) + + def _compute_item_count(self): + for rec in self: + rec.item_count = len(rec.item_ids) + + +class EncoachFaqItem(models.Model): + _name = 'encoach.faq.item' + _description = 'FAQ Item' + _order = 'sequence, id' + + category_id = fields.Many2one('encoach.faq.category', required=True, ondelete='cascade') + question = fields.Char(required=True) + answer = fields.Text(required=True) + sequence = fields.Integer(default=10) + audience = fields.Selection([ + ('all', 'All'), + ('student', 'Students'), + ('teacher', 'Teachers'), + ('admin', 'Admins'), + ], default='all') + is_published = fields.Boolean(default=True) diff --git a/custom_addons/encoach_lms_api/models/gradebook.py b/custom_addons/encoach_lms_api/models/gradebook.py new file mode 100644 index 0000000..f470857 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/gradebook.py @@ -0,0 +1,26 @@ +from odoo import models, fields + + +class EncoachGradebook(models.Model): + _name = 'encoach.gradebook' + _description = 'Gradebook' + + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + course_id = fields.Many2one('op.course', required=True, ondelete='cascade') + academic_year_id = fields.Many2one('op.academic.year', ondelete='set null') + line_ids = fields.One2many('encoach.gradebook.line', 'gradebook_id') + + +class EncoachGradebookLine(models.Model): + _name = 'encoach.gradebook.line' + _description = 'Gradebook Line' + + gradebook_id = fields.Many2one('encoach.gradebook', required=True, ondelete='cascade') + assignment_name = fields.Char() + marks = fields.Float() + percentage = fields.Float() + state = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted'), + ('graded', 'Graded'), + ], default='draft') diff --git a/custom_addons/encoach_lms_api/models/lesson.py b/custom_addons/encoach_lms_api/models/lesson.py new file mode 100644 index 0000000..30bb5c2 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/lesson.py @@ -0,0 +1,24 @@ +from odoo import models, fields + + +class EncoachLesson(models.Model): + _name = 'encoach.lesson' + _description = 'Lesson' + _order = 'start_datetime desc, id desc' + + name = fields.Char(string='Name') + lesson_topic = fields.Char(string='Topic') + course_id = fields.Many2one('op.course', string='Course', ondelete='cascade') + batch_id = fields.Many2one('op.batch', string='Batch', ondelete='set null') + subject_id = fields.Many2one('op.subject', string='Subject', ondelete='set null') + session_id = fields.Many2one('op.session', string='Session', ondelete='set null') + faculty_id = fields.Many2one('op.faculty', string='Faculty', ondelete='set null') + start_datetime = fields.Datetime(string='Start') + end_datetime = fields.Datetime(string='End') + content = fields.Text(string='Content') + status = fields.Selection([ + ('draft', 'Draft'), + ('scheduled', 'Scheduled'), + ('done', 'Done'), + ('cancelled', 'Cancelled'), + ], default='draft', string='Status') diff --git a/custom_addons/encoach_lms_api/models/notification.py b/custom_addons/encoach_lms_api/models/notification.py new file mode 100644 index 0000000..d6b0356 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/notification.py @@ -0,0 +1,54 @@ +from odoo import models, fields + + +class EncoachNotification(models.Model): + _name = 'encoach.notification' + _description = 'Notification' + _order = 'create_date desc' + + user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + title = fields.Char(required=True) + message = fields.Text() + type = fields.Selection([ + ('info', 'Info'), + ('warning', 'Warning'), + ('success', 'Success'), + ('error', 'Error'), + ], default='info') + is_read = fields.Boolean(default=False) + read_at = fields.Datetime() + reference_model = fields.Char() + reference_id = fields.Integer() + + +class EncoachNotificationRule(models.Model): + _name = 'encoach.notification.rule' + _description = 'Notification Rule' + + name = fields.Char(required=True) + event_type = fields.Char(required=True) + template = fields.Text() + is_active = fields.Boolean(default=True) + recipients = fields.Selection([ + ('all', 'All Users'), + ('students', 'Students Only'), + ('teachers', 'Teachers Only'), + ('admins', 'Admins Only'), + ], default='all') + channels = fields.Char(help='Comma-separated: email,push,in_app') + + +class EncoachNotificationPreference(models.Model): + _name = 'encoach.notification.preference' + _description = 'Notification Preference' + _rec_name = 'user_id' + + user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True) + email_enabled = fields.Boolean(default=True) + push_enabled = fields.Boolean(default=True) + in_app_enabled = fields.Boolean(default=True) + digest_frequency = fields.Selection([ + ('realtime', 'Real-time'), + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ], default='realtime') diff --git a/custom_addons/encoach_lms_api/models/student_leave.py b/custom_addons/encoach_lms_api/models/student_leave.py new file mode 100644 index 0000000..748f95d --- /dev/null +++ b/custom_addons/encoach_lms_api/models/student_leave.py @@ -0,0 +1,46 @@ +from odoo import models, fields, api + + +class EncoachStudentLeaveType(models.Model): + _name = 'encoach.student.leave.type' + _description = 'Student Leave Type' + + name = fields.Char(required=True) + + +class EncoachStudentLeave(models.Model): + _name = 'encoach.student.leave' + _description = 'Student Leave Request' + _order = 'create_date desc' + + request_number = fields.Char(string='Request #', readonly=True, copy=False) + student_id = fields.Many2one('op.student', required=True, ondelete='cascade') + leave_type_id = fields.Many2one('encoach.student.leave.type', required=True, ondelete='restrict') + start_date = fields.Date(required=True) + end_date = fields.Date(required=True) + duration = fields.Integer(compute='_compute_duration', store=True) + description = fields.Text() + state = fields.Selection([ + ('draft', 'Draft'), + ('confirm', 'Confirmed'), + ('approve', 'Approved'), + ('refuse', 'Refused'), + ('cancel', 'Cancelled'), + ], default='draft') + approve_date = fields.Date() + approved_by = fields.Many2one('res.users', ondelete='set null') + + @api.depends('start_date', 'end_date') + def _compute_duration(self): + for rec in self: + if rec.start_date and rec.end_date: + rec.duration = (rec.end_date - rec.start_date).days + 1 + else: + rec.duration = 0 + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('request_number'): + vals['request_number'] = self.env['ir.sequence'].next_by_code('encoach.student.leave') or 'SL-NEW' + return super().create(vals_list) diff --git a/custom_addons/encoach_lms_api/models/ticket.py b/custom_addons/encoach_lms_api/models/ticket.py new file mode 100644 index 0000000..c2ddcd2 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/ticket.py @@ -0,0 +1,37 @@ +from odoo import fields, models + + +class EncoachTicket(models.Model): + _name = 'encoach.ticket' + _description = 'Support Ticket' + _inherit = ['mail.thread'] + _order = 'id desc' + + subject = fields.Char('Subject', required=True, tracking=True) + description = fields.Text('Description') + type = fields.Selection([ + ('bug', 'Bug'), + ('feature', 'Feature'), + ('help', 'Help'), + ('support', 'Support'), + ('feedback', 'Feedback'), + ], string='Type', default='support', required=True, tracking=True) + status = fields.Selection([ + ('open', 'Open'), + ('in_progress', 'In Progress'), + ('resolved', 'Resolved'), + ('closed', 'Closed'), + ], string='Status', default='open', required=True, tracking=True) + source = fields.Selection([ + ('platform', 'Platform'), + ('webpage', 'Webpage'), + ('email', 'Email'), + ], string='Source', default='platform') + + page_context = fields.Char('Page Context') + reporter_id = fields.Many2one('res.users', string='Reporter', required=True, + default=lambda self: self.env.user.id) + assignee_id = fields.Many2one('res.users', string='Assignee') + entity_id = fields.Many2one('encoach.entity', string='Entity') + + resolved_at = fields.Datetime('Resolved At') diff --git a/custom_addons/encoach_lms_api/models/training.py b/custom_addons/encoach_lms_api/models/training.py new file mode 100644 index 0000000..c2574e8 --- /dev/null +++ b/custom_addons/encoach_lms_api/models/training.py @@ -0,0 +1,157 @@ +"""Training library: vocabulary items, grammar rules and per-user progress. + +Backs the `/admin/training/vocabulary` and `/admin/training/grammar` pages +(previously hard-coded mocks). +""" +from odoo import api, fields, models + + +CEFR_LEVELS = [ + ('A1', 'A1'), + ('A2', 'A2'), + ('B1', 'B1'), + ('B2', 'B2'), + ('C1', 'C1'), + ('C2', 'C2'), +] + + +class EncoachVocabItem(models.Model): + _name = 'encoach.vocab.item' + _description = 'Training Vocabulary Item' + _order = 'level,word' + + word = fields.Char('Word', required=True, index=True) + meaning = fields.Text('Meaning', required=True) + example_sentence = fields.Text('Example Sentence') + level = fields.Selection(CEFR_LEVELS, string='CEFR Level', default='B1', required=True) + part_of_speech = fields.Selection([ + ('noun', 'Noun'), + ('verb', 'Verb'), + ('adjective', 'Adjective'), + ('adverb', 'Adverb'), + ('phrase', 'Phrase'), + ('other', 'Other'), + ], string='Part of Speech', default='noun') + category = fields.Char('Category', default='general', + help='Tag such as "academic", "business", "daily".') + active = fields.Boolean('Active', default=True) + + progress_ids = fields.One2many('encoach.vocab.progress', 'vocab_id', 'Progress') + + completion_count = fields.Integer('Completions', compute='_compute_stats') + learners_count = fields.Integer('Learners', compute='_compute_stats') + + @api.depends('progress_ids.completed') + def _compute_stats(self): + for rec in self: + rec.learners_count = len(rec.progress_ids) + rec.completion_count = len(rec.progress_ids.filtered(lambda p: p.completed)) + + @api.constrains('word') + def _check_word_unique(self): + for rec in self: + if not rec.word: + continue + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('word', '=ilike', rec.word.strip()), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError(f"Word '{rec.word}' already exists.") + + +class EncoachVocabProgress(models.Model): + _name = 'encoach.vocab.progress' + _description = 'Vocabulary Progress' + _order = 'last_reviewed desc' + + user_id = fields.Many2one('res.users', required=True, + default=lambda self: self.env.user.id, + ondelete='cascade') + vocab_id = fields.Many2one('encoach.vocab.item', required=True, ondelete='cascade') + completed = fields.Boolean('Completed', default=False) + mastery = fields.Selection([ + ('learning', 'Learning'), + ('familiar', 'Familiar'), + ('mastered', 'Mastered'), + ], default='learning') + last_reviewed = fields.Datetime('Last Reviewed') + review_count = fields.Integer('Review Count', default=0) + + @api.constrains('user_id', 'vocab_id') + def _check_unique_user_vocab(self): + for rec in self: + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('user_id', '=', rec.user_id.id), + ('vocab_id', '=', rec.vocab_id.id), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError("Progress already exists for this user + vocabulary item.") + + +class EncoachGrammarRule(models.Model): + _name = 'encoach.grammar.rule' + _description = 'Training Grammar Rule' + _order = 'level,name' + + name = fields.Char('Rule', required=True, index=True) + description = fields.Text('Description', required=True) + example = fields.Text('Example') + level = fields.Selection(CEFR_LEVELS, string='CEFR Level', default='B1', required=True) + category = fields.Char('Category', default='general', + help='Tag such as "tenses", "modals", "clauses".') + active = fields.Boolean('Active', default=True) + + progress_ids = fields.One2many('encoach.grammar.progress', 'rule_id', 'Progress') + + completion_count = fields.Integer('Completions', compute='_compute_stats') + learners_count = fields.Integer('Learners', compute='_compute_stats') + + @api.depends('progress_ids.completed') + def _compute_stats(self): + for rec in self: + rec.learners_count = len(rec.progress_ids) + rec.completion_count = len(rec.progress_ids.filtered(lambda p: p.completed)) + + @api.constrains('name') + def _check_rule_name_unique(self): + for rec in self: + if not rec.name: + continue + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('name', '=ilike', rec.name.strip()), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError(f"Grammar rule '{rec.name}' already exists.") + + +class EncoachGrammarProgress(models.Model): + _name = 'encoach.grammar.progress' + _description = 'Grammar Rule Progress' + _order = 'last_reviewed desc' + + user_id = fields.Many2one('res.users', required=True, + default=lambda self: self.env.user.id, + ondelete='cascade') + rule_id = fields.Many2one('encoach.grammar.rule', required=True, ondelete='cascade') + completed = fields.Boolean('Completed', default=False) + last_reviewed = fields.Datetime('Last Reviewed') + review_count = fields.Integer('Review Count', default=0) + + @api.constrains('user_id', 'rule_id') + def _check_unique_user_rule(self): + for rec in self: + dup = self.sudo().search([ + ('id', '!=', rec.id), + ('user_id', '=', rec.user_id.id), + ('rule_id', '=', rec.rule_id.id), + ], limit=1) + if dup: + from odoo.exceptions import ValidationError + raise ValidationError("Progress already exists for this user + grammar rule.") diff --git a/custom_addons/encoach_lms_api/security/ir.model.access.csv b/custom_addons/encoach_lms_api/security/ir.model.access.csv new file mode 100644 index 0000000..98c7296 --- /dev/null +++ b/custom_addons/encoach_lms_api/security/ir.model.access.csv @@ -0,0 +1,25 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_lesson_all,encoach.lesson.all,model_encoach_lesson,base.group_user,1,1,1,1 +access_encoach_student_leave_type_all,encoach.student.leave.type.all,model_encoach_student_leave_type,base.group_user,1,1,1,1 +access_encoach_student_leave_all,encoach.student.leave.all,model_encoach_student_leave,base.group_user,1,1,1,1 +access_encoach_gradebook_all,encoach.gradebook.all,model_encoach_gradebook,base.group_user,1,1,1,1 +access_encoach_gradebook_line_all,encoach.gradebook.line.all,model_encoach_gradebook_line,base.group_user,1,1,1,1 +access_encoach_discussion_board_all,encoach.discussion.board.all,model_encoach_discussion_board,base.group_user,1,1,1,1 +access_encoach_discussion_post_all,encoach.discussion.post.all,model_encoach_discussion_post,base.group_user,1,1,1,1 +access_encoach_announcement_all,encoach.announcement.all,model_encoach_announcement,base.group_user,1,1,1,1 +access_encoach_message_all,encoach.message.all,model_encoach_message,base.group_user,1,1,1,1 +access_encoach_course_chapter_all,encoach.course.chapter.all,model_encoach_course_chapter,base.group_user,1,1,1,1 +access_encoach_chapter_material_all,encoach.chapter.material.all,model_encoach_chapter_material,base.group_user,1,1,1,1 +access_encoach_chapter_progress_all,encoach.chapter.progress.all,model_encoach_chapter_progress,base.group_user,1,1,1,1 +access_encoach_notification_all,encoach.notification.all,model_encoach_notification,base.group_user,1,1,1,1 +access_encoach_notification_rule_all,encoach.notification.rule.all,model_encoach_notification_rule,base.group_user,1,1,1,1 +access_encoach_notification_pref_all,encoach.notification.preference.all,model_encoach_notification_preference,base.group_user,1,1,1,1 +access_encoach_faq_category_all,encoach.faq.category.all,model_encoach_faq_category,base.group_user,1,1,1,1 +access_encoach_faq_item_all,encoach.faq.item.all,model_encoach_faq_item,base.group_user,1,1,1,1 +access_encoach_course_completion_all,encoach.course.completion.all,model_encoach_course_completion,base.group_user,1,1,1,1 +access_encoach_asset_all,encoach.asset.all,model_encoach_asset,base.group_user,1,1,1,1 +access_encoach_ticket_all,encoach.ticket.all,model_encoach_ticket,base.group_user,1,1,1,1 +access_encoach_vocab_item_all,encoach.vocab.item.all,model_encoach_vocab_item,base.group_user,1,1,1,1 +access_encoach_vocab_progress_all,encoach.vocab.progress.all,model_encoach_vocab_progress,base.group_user,1,1,1,1 +access_encoach_grammar_rule_all,encoach.grammar.rule.all,model_encoach_grammar_rule,base.group_user,1,1,1,1 +access_encoach_grammar_progress_all,encoach.grammar.progress.all,model_encoach_grammar_progress,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_resources/models/__init__.py b/custom_addons/encoach_resources/models/__init__.py index 364a06e..6bbf7a9 100644 --- a/custom_addons/encoach_resources/models/__init__.py +++ b/custom_addons/encoach_resources/models/__init__.py @@ -1 +1,2 @@ from . import resource +from . import resource_tag diff --git a/custom_addons/encoach_resources/models/resource.py b/custom_addons/encoach_resources/models/resource.py index 65f9733..81f2dec 100644 --- a/custom_addons/encoach_resources/models/resource.py +++ b/custom_addons/encoach_resources/models/resource.py @@ -19,7 +19,16 @@ class EncoachResource(models.Model): ('rejected', 'Rejected'), ], default='approved') subject_id = fields.Many2one('encoach.subject', ondelete='set null') + domain_id = fields.Many2one('encoach.domain', ondelete='set null', string='Domain') topic_ids = fields.Many2many('encoach.topic') + learning_objective_ids = fields.Many2many( + 'encoach.learning.objective', 'encoach_resource_obj_rel', + 'resource_id', 'objective_id', string='Learning Objectives', + ) + tag_ids = fields.Many2many( + 'encoach.resource.tag', 'encoach_resource_tag_rel', + 'resource_id', 'tag_id', string='Tags', + ) file = fields.Binary(attachment=True) url = fields.Char() difficulty = fields.Selection([ @@ -39,6 +48,22 @@ class EncoachResource(models.Model): approved = fields.Boolean(default=False) ielts_certified = fields.Boolean(default=False) + def _get_course_count(self): + Mat = self.env.get('encoach.chapter.material') + if Mat is None: + for rec in self: + rec.course_count = 0 + return + Mat = self.env['encoach.chapter.material'].sudo() + for rec in self: + mats = Mat.search([('resource_id', '=', rec.id)]) + rec.course_count = len(set( + m.chapter_id.course_id.id for m in mats + if m.chapter_id and m.chapter_id.course_id + )) + + course_count = fields.Integer(compute='_get_course_count') + def to_api_dict(self): self.ensure_one() creator = self.creator_id @@ -48,8 +73,13 @@ class EncoachResource(models.Model): 'type': self.type or '', 'resource_type': self.type or 'document', 'subject_id': self.subject_id.id if self.subject_id else None, + 'subject_name': self.subject_id.name if self.subject_id else '', + 'domain_id': self.domain_id.id if self.domain_id else None, + 'domain_name': self.domain_id.name if self.domain_id else '', 'topic_ids': self.topic_ids.ids, 'topic_names': self.topic_ids.mapped('name'), + 'learning_objective_ids': self.learning_objective_ids.ids, + 'learning_objective_names': self.learning_objective_ids.mapped('name'), 'url': self.url or '', 'has_file': bool(self.file), 'difficulty': self.difficulty or '', @@ -61,8 +91,12 @@ class EncoachResource(models.Model): 'cefr_level': self.cefr_level or '', 'grammar_topic': self.grammar_topic or '', 'vocab_band': self.vocab_band or '', + 'tag_ids': self.tag_ids.ids, + 'tag_names': self.tag_ids.mapped('name'), + 'tags': [{'id': t.id, 'name': t.name, 'color': t.color or '#6b7280'} for t in self.tag_ids], 'ai_generated': self.ai_generated, 'approved': self.approved, + 'course_count': self.course_count, 'created_at': self.create_date.isoformat() if self.create_date else '', 'file_name': self.name, } diff --git a/custom_addons/encoach_resources/models/resource_tag.py b/custom_addons/encoach_resources/models/resource_tag.py new file mode 100644 index 0000000..e57c800 --- /dev/null +++ b/custom_addons/encoach_resources/models/resource_tag.py @@ -0,0 +1,30 @@ +from odoo import models, fields + + +class EncoachResourceTag(models.Model): + _name = 'encoach.resource.tag' + _description = 'Resource Tag' + _order = 'name' + + name = fields.Char(required=True, index=True) + color = fields.Char(size=20, help='Hex color code for UI badge, e.g. #3b82f6') + description = fields.Text() + active = fields.Boolean(default=True) + resource_ids = fields.Many2many( + 'encoach.resource', 'encoach_resource_tag_rel', + 'tag_id', 'resource_id', string='Resources', + ) + + _sql_constraints = [ + ('name_uniq', 'unique(name)', 'Tag name must be unique.'), + ] + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'name': self.name, + 'color': self.color or '#6b7280', + 'description': self.description or '', + 'resource_count': len(self.resource_ids), + } diff --git a/custom_addons/encoach_resources/security/ir.model.access.csv b/custom_addons/encoach_resources/security/ir.model.access.csv index be39630..45cb93a 100644 --- a/custom_addons/encoach_resources/security/ir.model.access.csv +++ b/custom_addons/encoach_resources/security/ir.model.access.csv @@ -1,2 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_encoach_resource_user,encoach.resource.user,model_encoach_resource,base.group_user,1,1,1,1 +access_encoach_resource_tag_user,encoach.resource.tag.user,model_encoach_resource_tag,base.group_user,1,1,1,1 diff --git a/custom_addons/encoach_taxonomy/controllers/taxonomy.py b/custom_addons/encoach_taxonomy/controllers/taxonomy.py index 5479cd6..be2ede8 100644 --- a/custom_addons/encoach_taxonomy/controllers/taxonomy.py +++ b/custom_addons/encoach_taxonomy/controllers/taxonomy.py @@ -22,39 +22,331 @@ PARENT_FIELD_MAP = { } +def _subject_dict(s): + return { + 'id': s.id, + 'name': s.name, + 'code': s.code or '', + 'description': s.description or '', + 'is_active': s.active, + 'active': s.active, + 'domain_count': len(s.domain_ids), + 'topic_count': sum(len(d.topic_ids) for d in s.domain_ids), + 'course_count': s.course_count if hasattr(s, 'course_count') else 0, + 'resource_count': s.resource_count if hasattr(s, 'resource_count') else 0, + 'mastery_threshold': 80, + 'grading_scale': 'percentage', + 'diagnostic_config': { + 'questions_per_domain': 5, + 'total_question_cap': 30, + 'time_limit_minutes': 45, + 'starting_difficulty': 'medium', + 'mastery_per_correct': 10, + 'mastery_per_incorrect': -5, + }, + } + + +def _domain_dict(d): + return { + 'id': d.id, + 'name': d.name, + 'code': '', + 'subject_id': d.subject_id.id, + 'subject_name': d.subject_id.name, + 'sequence': d.sequence, + 'description': d.description or '', + 'topic_count': len(d.topic_ids), + } + + +def _topic_dict(t): + return { + 'id': t.id, + 'name': t.name, + 'code': '', + 'domain_id': t.domain_id.id, + 'domain_name': t.domain_id.name, + 'description': t.description or '', + 'difficulty_level': 'medium', + 'estimated_hours': 1, + 'prerequisite_ids': [], + 'prerequisite_names': [], + 'question_type_weights': {}, + 'objective_count': len(t.learning_objective_ids), + 'resource_count': t.resource_count if hasattr(t, 'resource_count') else 0, + 'chapter_count': t.chapter_count if hasattr(t, 'chapter_count') else 0, + } + + +def _objective_dict(o): + return { + 'id': o.id, + 'name': o.name, + 'topic_id': o.topic_id.id, + 'bloom_level': o.bloom_level or 'remember', + 'sequence': o.sequence if hasattr(o, 'sequence') else 0, + } + + class EncoachTaxonomyController(http.Controller): - # ------------------------------------------------------------------ - # GET /api/taxonomy/subjects - # ------------------------------------------------------------------ - @http.route('/api/taxonomy/subjects', type='http', auth='none', - methods=['GET'], csrf=False) + # ══════════════════════════════════════════════════════════════════ + # SUBJECTS /api/subjects + # ══════════════════════════════════════════════════════════════════ + + @http.route(['/api/subjects', '/api/taxonomy/subjects'], type='http', + auth='public', methods=['GET'], csrf=False) @jwt_required - def get_subjects(self, **kw): + def list_subjects(self, **kw): try: - Subject = request.env['encoach.subject'].sudo() - subjects = Subject.search([('active', '=', True)]) - - items = [] - for s in subjects: - items.append({ - 'id': s.id, - 'name': s.name, - 'code': s.code or '', - 'description': s.description or '', - 'domain_count': len(s.domain_ids), - }) - - return _json_response({'subjects': items}) - + recs = request.env['encoach.subject'].sudo().search([]) + items = [_subject_dict(s) for s in recs] + return _json_response({'data': items, 'subjects': items}) except Exception as e: - _logger.exception('get_subjects failed') + _logger.exception('list_subjects') return _error_response(str(e), 500) - # ------------------------------------------------------------------ - # GET /api/taxonomy/tree - # ------------------------------------------------------------------ - @http.route('/api/taxonomy/tree', type='http', auth='none', + @http.route('/api/subjects/', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_subject(self, sid, **kw): + try: + rec = request.env['encoach.subject'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Subject not found', 404) + return _json_response({'data': _subject_dict(rec)}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_subject(self, **kw): + try: + body = _get_json_body() + vals = {'name': body.get('name', ''), 'code': body.get('code', '')} + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.subject'].sudo().create(vals) + return _json_response(_subject_dict(rec), 201) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_subject(self, sid, **kw): + try: + rec = request.env['encoach.subject'].sudo().browse(sid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'code', 'description'): + if k in body: + vals[k] = body[k] + if 'is_active' in body: + vals['active'] = bool(body['is_active']) + if vals: + rec.write(vals) + return _json_response(_subject_dict(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_subject(self, sid, **kw): + try: + rec = request.env['encoach.subject'].sudo().browse(sid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/subjects//taxonomy', type='http', + auth='public', methods=['GET'], csrf=False) + @jwt_required + def get_subject_taxonomy(self, sid, **kw): + try: + s = request.env['encoach.subject'].sudo().browse(sid) + if not s.exists(): + return _error_response('Subject not found', 404) + domains = [] + for d in s.domain_ids: + topics = [] + for t in d.topic_ids: + objectives = [_objective_dict(o) for o in t.learning_objective_ids] + td = _topic_dict(t) + td['objectives'] = objectives + topics.append(td) + dd = _domain_dict(d) + dd['topics'] = topics + domains.append(dd) + result = _subject_dict(s) + result['domains'] = domains + return _json_response({'data': {'subject': _subject_dict(s), 'domains': domains}}) + except Exception as e: + _logger.exception('get_subject_taxonomy') + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # DOMAINS /api/domains + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/domains', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_domains(self, **kw): + try: + domain_filter = [] + if kw.get('subject_id'): + domain_filter.append(('subject_id', '=', int(kw['subject_id']))) + recs = request.env['encoach.domain'].sudo().search(domain_filter) + return _json_response({'data': [_domain_dict(d) for d in recs]}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_domain(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'subject_id': int(body['subject_id']), + } + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.domain'].sudo().create(vals) + return _json_response(_domain_dict(rec), 201) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_domain(self, did, **kw): + try: + rec = request.env['encoach.domain'].sudo().browse(did) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'description'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response(_domain_dict(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_domain(self, did, **kw): + try: + rec = request.env['encoach.domain'].sudo().browse(did) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/domains//ai-suggest', type='http', + auth='public', methods=['POST'], csrf=False) + @jwt_required + def ai_suggest_topics(self, did, **kw): + try: + domain = request.env['encoach.domain'].sudo().browse(did) + if not domain.exists(): + return _error_response('Domain not found', 404) + suggestions = [ + {'name': f'{domain.name} - Fundamentals', 'difficulty_level': 'easy', 'estimated_hours': 2}, + {'name': f'{domain.name} - Intermediate Concepts', 'difficulty_level': 'medium', 'estimated_hours': 3}, + {'name': f'{domain.name} - Advanced Topics', 'difficulty_level': 'hard', 'estimated_hours': 4}, + ] + return _json_response({'suggestions': suggestions}) + except Exception as e: + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # TOPICS /api/topics + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/topics', type='http', auth='public', + methods=['GET'], csrf=False) + @jwt_required + def list_topics(self, **kw): + try: + domain_filter = [] + if kw.get('domain_id'): + domain_filter.append(('domain_id', '=', int(kw['domain_id']))) + if kw.get('subject_id'): + domains = request.env['encoach.domain'].sudo().search([('subject_id', '=', int(kw['subject_id']))]) + domain_filter.append(('domain_id', 'in', domains.ids)) + recs = request.env['encoach.topic'].sudo().search(domain_filter) + return _json_response({'data': [_topic_dict(t) for t in recs]}) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/topics', type='http', auth='public', + methods=['POST'], csrf=False) + @jwt_required + def create_topic(self, **kw): + try: + body = _get_json_body() + vals = { + 'name': body.get('name', ''), + 'domain_id': int(body['domain_id']), + } + if body.get('description'): + vals['description'] = body['description'] + rec = request.env['encoach.topic'].sudo().create(vals) + return _json_response(_topic_dict(rec), 201) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/topics/', type='http', auth='public', + methods=['PATCH', 'PUT'], csrf=False) + @jwt_required + def update_topic(self, tid, **kw): + try: + rec = request.env['encoach.topic'].sudo().browse(tid) + if not rec.exists(): + return _error_response('Not found', 404) + body = _get_json_body() + vals = {} + for k in ('name', 'description'): + if k in body: + vals[k] = body[k] + if vals: + rec.write(vals) + return _json_response(_topic_dict(rec)) + except Exception as e: + return _error_response(str(e), 500) + + @http.route('/api/topics/', type='http', auth='public', + methods=['DELETE'], csrf=False) + @jwt_required + def delete_topic(self, tid, **kw): + try: + rec = request.env['encoach.topic'].sudo().browse(tid) + if rec.exists(): + rec.unlink() + return _json_response({'success': True}) + except Exception as e: + return _error_response(str(e), 500) + + # ══════════════════════════════════════════════════════════════════ + # Legacy /api/taxonomy/* routes + # ══════════════════════════════════════════════════════════════════ + + @http.route('/api/taxonomy/tree', type='http', auth='public', methods=['GET'], csrf=False) @jwt_required def get_tree(self, **kw): diff --git a/custom_addons/encoach_taxonomy/models/learning_objective.py b/custom_addons/encoach_taxonomy/models/learning_objective.py index 23a1e0e..59c7751 100644 --- a/custom_addons/encoach_taxonomy/models/learning_objective.py +++ b/custom_addons/encoach_taxonomy/models/learning_objective.py @@ -9,6 +9,14 @@ class EncoachLearningObjective(models.Model): name = fields.Char(required=True, index=True) topic_id = fields.Many2one('encoach.topic', required=True, ondelete='cascade', index=True) description = fields.Text() + bloom_level = fields.Selection([ + ('remember', 'Remember'), + ('understand', 'Understand'), + ('apply', 'Apply'), + ('analyze', 'Analyze'), + ('evaluate', 'Evaluate'), + ('create', 'Create'), + ], default='remember') cefr_level = fields.Selection([ ('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'), ('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'), diff --git a/custom_addons/encoach_taxonomy/models/subject.py b/custom_addons/encoach_taxonomy/models/subject.py index ed3eb8b..6f031f0 100644 --- a/custom_addons/encoach_taxonomy/models/subject.py +++ b/custom_addons/encoach_taxonomy/models/subject.py @@ -13,6 +13,18 @@ class EncoachSubject(models.Model): active = fields.Boolean(default=True) domain_ids = fields.One2many('encoach.domain', 'subject_id', string='Domains') + course_count = fields.Integer(compute='_compute_counts') + resource_count = fields.Integer(compute='_compute_counts') + + def _compute_counts(self): + for rec in self: + rec.course_count = self.env['op.course'].sudo().search_count( + [('encoach_subject_id', '=', rec.id)] + ) + rec.resource_count = self.env['encoach.resource'].sudo().search_count( + [('subject_id', '=', rec.id)] + ) + def to_api_dict(self): self.ensure_one() return { @@ -21,4 +33,6 @@ class EncoachSubject(models.Model): 'code': self.code or '', 'description': self.description or '', 'active': self.active, + 'course_count': self.course_count, + 'resource_count': self.resource_count, } diff --git a/custom_addons/encoach_taxonomy/models/topic.py b/custom_addons/encoach_taxonomy/models/topic.py index 00fd7cf..da00fe3 100644 --- a/custom_addons/encoach_taxonomy/models/topic.py +++ b/custom_addons/encoach_taxonomy/models/topic.py @@ -13,6 +13,18 @@ class EncoachTopic(models.Model): active = fields.Boolean(default=True) learning_objective_ids = fields.One2many('encoach.learning.objective', 'topic_id', string='Learning Objectives') + resource_count = fields.Integer(compute='_compute_counts') + chapter_count = fields.Integer(compute='_compute_counts') + + def _compute_counts(self): + for rec in self: + rec.resource_count = self.env['encoach.resource'].sudo().search_count( + [('topic_ids', 'in', [rec.id])] + ) + rec.chapter_count = self.env['encoach.course.chapter'].sudo().search_count( + [('topic_id', '=', rec.id)] + ) + def to_api_dict(self): self.ensure_one() return { @@ -22,4 +34,6 @@ class EncoachTopic(models.Model): 'domain_name': self.domain_id.name, 'description': self.description or '', 'active': self.active, + 'resource_count': self.resource_count, + 'chapter_count': self.chapter_count, } diff --git a/custom_addons/encoach_vector/models/embedding.py b/custom_addons/encoach_vector/models/embedding.py index 4877063..dec64f0 100644 --- a/custom_addons/encoach_vector/models/embedding.py +++ b/custom_addons/encoach_vector/models/embedding.py @@ -22,6 +22,7 @@ class EncoachEmbedding(models.Model): ('topic', 'Topic'), ('feedback', 'Feedback'), ('generation_log', 'Generation Log'), + ('material', 'Course Material'), ], required=True, index=True) content_id = fields.Integer(required=True, index=True) content_text = fields.Text() diff --git a/custom_addons/encoach_vector/services/indexer.py b/custom_addons/encoach_vector/services/indexer.py index 3e74bd6..1766c7a 100644 --- a/custom_addons/encoach_vector/services/indexer.py +++ b/custom_addons/encoach_vector/services/indexer.py @@ -40,6 +40,13 @@ MODEL_CONFIG = [ 'description_field': 'generated_content', 'metadata_fields': ['course_type', 'status'], }, + { + 'model': 'encoach.chapter.material', + 'content_type': 'material', + 'text_field': 'name', + 'description_field': 'description', + 'metadata_fields': ['type'], + }, ]