feat: institutional + support + training admin sections (backend + frontend)

Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent 50f58dc995
commit 98b9837a54
141 changed files with 20235 additions and 1453 deletions

View File

@@ -0,0 +1,30 @@
---
description: EnCoach project context — auto-load summary and update it at end of session
alwaysApply: true
---
# EnCoach Project Context
At the **start of every chat**, read `docs/PROJECT_SUMMARY.md` to understand the project state, credentials, architecture, API routes, and how to run the project. This is your primary reference for the EnCoach platform.
At the **end of every chat** (when the user says goodbye, asks to wrap up, or the session's main task is complete), update `docs/PROJECT_SUMMARY.md` with any new information discovered or changes made during the session. This includes:
- New or changed user credentials
- New API routes or changed endpoints
- New models or schema changes
- New modules installed or removed
- Git state changes (new branches, remotes, pushes)
- Bug fixes or known issues discovered
- New pages or components added to frontend
- Any configuration changes (odoo.conf, .env, etc.)
## Key Facts
- **Backend**: Odoo 19 at `http://127.0.0.1:8069`
- **Frontend**: Vite React at `http://localhost:8080`
- **Database**: `encoach_v2` on PostgreSQL 18
- **Login route**: `POST /api/login` (not `/api/auth/login`)
- **PostgreSQL binary**: `/Users/yamenahmad/micromamba/envs/odoo19/bin/pg_ctl` (PG 18 — must use this, not conda PG 17)
- **Node.js**: `/Users/yamenahmad/.local/node/bin/` (must add to PATH)
- **odoo.conf must have**: `db_name = encoach_v2` and `dbfilter = ^encoach_v2$`
- **Git branch**: `v4`

6
.gitignore vendored
View File

@@ -20,6 +20,12 @@ miniconda3/
# PostgreSQL data
pgdata/
pgdata_bak_*/
# Frontend build cache
frontend/.vite/
frontend/dist/
frontend/node_modules/
# IDE
.vscode/

View File

@@ -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/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/domains/<int:domain_id>/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/<string:module>/generate", type="http", auth="none", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/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/<module>/generate/save — save generated exam items ──
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="none", methods=["POST"], csrf=False)
@http.route("/api/exam/<string:module>/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/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="public", methods=["POST"], csrf=False)
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:

View File

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

View File

@@ -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/<string:video_id>", type="http", auth="user", methods=["GET"], csrf=False)
@http.route("/api/exam/avatar/video/<string:video_id>", 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:

View File

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

View File

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

View File

@@ -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 = []

View File

@@ -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/<int:entity_id>', 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/<int:entity_id>', 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/<int:entity_id>', 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/<int:entity_id>/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/<int:entity_id>/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)

View File

@@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

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

View File

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

View File

@@ -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/<int:yid>', 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/<int:yid>', 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/<int:yid>', 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/<int:yid>/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/<int:tid>', 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/<int:tid>', 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/<int:did>', 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/<int:did>', 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)

View File

@@ -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/<int:aid>', 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/<int:aid>', 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/<int:tid>', 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)

View File

@@ -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/<int:rid>', 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/<int:rid>/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/<int:rid>/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/<int:rid>', 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/<int:aid>', 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/<int:aid>', 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/<int:aid>', 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/<int:aid>/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/<int:aid>/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/<int:aid>/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/<int:aid>/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)

View File

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

View File

@@ -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/<int:gid>', 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/<int:gid>', 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/<int:gid>/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/<int:gid>/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)

View File

@@ -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/<int:bid>', 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/<int:bid>/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/<int:bid>/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/<int:pid>', 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/<int:pid>', 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/<int:pid>/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/<int:pid>/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/<int:aid>', 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/<int:aid>', 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/<int:aid>/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/<int:mid>', 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/<int:mid>', 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)

View File

@@ -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/<int:course_id>/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/<int:course_id>/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/<int:cid>', 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/<int:cid>', 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/<int:cid>', 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/<int:cid>/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/<int:cid>/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/<int:course_id>/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/<int:cid>/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/<int:cid>/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/<int:course_id>/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/<int:cid>/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/<int:cid>/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/<int:cid>/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/<int:mid>', 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/<int:mid>', 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/<int:mid>/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/<int:mid>/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/<int:mid>/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/<int:cid>/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)

View File

@@ -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/<int:fid>', 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/<int:fid>', 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/<int:aid>', 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/<int:aid>', 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)

View File

@@ -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/<int:cid>', 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/<int:cid>', 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/<int:iid>', 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/<int:iid>', 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)

View File

@@ -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/<int:fid>', 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/<int:fid>/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/<int:fid>/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)

View File

@@ -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/<int:gid>', 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/<int:gid>', 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)

View File

@@ -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/<int:sid>', 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/<int:sid>', 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/<int:sid>', 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/<int:sid>/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/<int:sid>/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/<int:eid>', 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/<int:eid>/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/<int:eid>/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/<int:tid>/generate',
'/api/result-templates/<int:tid>/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/<int:tid>', 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/<int:tid>', 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/<int:mid>', 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/<int:mid>/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/<int:aid>', 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/<int:aid>', 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/<int:aid>/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/<int:aid>/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/<int:rid>', 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/<int:rid>/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/<int:rid>/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/<int:aid>/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/<int:sid>', 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)

View File

@@ -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/<int:lid>', 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/<int:lid>', 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/<int:lid>', 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)

View File

@@ -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/<int:mid>', 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/<int:mid>', 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/<int:mid>', 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/<int:mid>/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)

View File

@@ -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/<int:course_id>', 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/<int:course_id>', 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/<int:course_id>', 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/<int:student_id>/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/<int:course_id>/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/<int:student_id>', 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/<int:student_id>', 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/<int:student_id>', 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/<int:teacher_id>', 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/<int:batch_id>', 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/<int:batch_id>', 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/<int:batch_id>', 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/<int:batch_id>/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/<int:batch_id>/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/<int:batch_id>/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/<int:course_id>/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/<int:subject_id>/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/<int:topic_id>/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)

View File

@@ -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/<int:nid>/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/<int:rid>', 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/<int:rid>', 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)

View File

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

View File

@@ -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/<int:cid>', 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/<int:pid>', 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/<int:pid>', 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)

View File

@@ -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/<int:rid>', 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/<int:rid>', 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/<int:rid>/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/<int:rid>/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/<int:rid>/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/<int:tid>', 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/<int:tid>', 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)

View File

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

View File

@@ -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/<int:lid>', 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/<int:lid>/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/<int:lid>', 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/<int:lid>/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)

View File

@@ -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/<int:sid>', 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)

View File

@@ -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/<int:tid>', 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/<int:tid>', 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/<int:tid>', 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)

View File

@@ -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/<int:sid>', 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)

View File

@@ -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/<int:vid>', 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/<int:vid>', 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/<int:vid>', 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/<int:vid>/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/<int:rid>', 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/<int:rid>', 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/<int:rid>', 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/<int:rid>/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)

View File

@@ -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/<int:uid_param>', 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/<int:rid>', 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/<int:rid>', 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/<int:rid>', 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/<int:rid>/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/<int:pid>', 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/<int:uid_param>/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/<int:uid_param>/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/<int:uid_param>/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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_lesson_all encoach.lesson.all model_encoach_lesson base.group_user 1 1 1 1
3 access_encoach_student_leave_type_all encoach.student.leave.type.all model_encoach_student_leave_type base.group_user 1 1 1 1
4 access_encoach_student_leave_all encoach.student.leave.all model_encoach_student_leave base.group_user 1 1 1 1
5 access_encoach_gradebook_all encoach.gradebook.all model_encoach_gradebook base.group_user 1 1 1 1
6 access_encoach_gradebook_line_all encoach.gradebook.line.all model_encoach_gradebook_line base.group_user 1 1 1 1
7 access_encoach_discussion_board_all encoach.discussion.board.all model_encoach_discussion_board base.group_user 1 1 1 1
8 access_encoach_discussion_post_all encoach.discussion.post.all model_encoach_discussion_post base.group_user 1 1 1 1
9 access_encoach_announcement_all encoach.announcement.all model_encoach_announcement base.group_user 1 1 1 1
10 access_encoach_message_all encoach.message.all model_encoach_message base.group_user 1 1 1 1
11 access_encoach_course_chapter_all encoach.course.chapter.all model_encoach_course_chapter base.group_user 1 1 1 1
12 access_encoach_chapter_material_all encoach.chapter.material.all model_encoach_chapter_material base.group_user 1 1 1 1
13 access_encoach_chapter_progress_all encoach.chapter.progress.all model_encoach_chapter_progress base.group_user 1 1 1 1
14 access_encoach_notification_all encoach.notification.all model_encoach_notification base.group_user 1 1 1 1
15 access_encoach_notification_rule_all encoach.notification.rule.all model_encoach_notification_rule base.group_user 1 1 1 1
16 access_encoach_notification_pref_all encoach.notification.preference.all model_encoach_notification_preference base.group_user 1 1 1 1
17 access_encoach_faq_category_all encoach.faq.category.all model_encoach_faq_category base.group_user 1 1 1 1
18 access_encoach_faq_item_all encoach.faq.item.all model_encoach_faq_item base.group_user 1 1 1 1
19 access_encoach_course_completion_all encoach.course.completion.all model_encoach_course_completion base.group_user 1 1 1 1
20 access_encoach_asset_all encoach.asset.all model_encoach_asset base.group_user 1 1 1 1
21 access_encoach_ticket_all encoach.ticket.all model_encoach_ticket base.group_user 1 1 1 1
22 access_encoach_vocab_item_all encoach.vocab.item.all model_encoach_vocab_item base.group_user 1 1 1 1
23 access_encoach_vocab_progress_all encoach.vocab.progress.all model_encoach_vocab_progress base.group_user 1 1 1 1
24 access_encoach_grammar_rule_all encoach.grammar.rule.all model_encoach_grammar_rule base.group_user 1 1 1 1
25 access_encoach_grammar_progress_all encoach.grammar.progress.all model_encoach_grammar_progress base.group_user 1 1 1 1

View File

@@ -1 +1,2 @@
from . import resource
from . import resource_tag

View File

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

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_resource_user encoach.resource.user model_encoach_resource base.group_user 1 1 1 1
3 access_encoach_resource_tag_user encoach.resource.tag.user model_encoach_resource_tag base.group_user 1 1 1 1

View File

@@ -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/<int:sid>', 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/<int:sid>', 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/<int:sid>', 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/<int:sid>/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/<int:did>', 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/<int:did>', 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/<int:did>/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/<int:tid>', 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/<int:tid>', 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):

View File

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

View File

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

View File

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

View File

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

View File

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

1072
docs/PROJECT_SUMMARY.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,14 @@
<title>EnCoach — IELTS & English Learning Platform</title>
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
<meta name="author" content="EnCoach" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#4A4068" />
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
<meta property="og:image" content="/logo-icon.png" />
<meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -61,6 +61,7 @@ import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
import TeacherStudents from "@/pages/teacher/TeacherStudents";
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
import TeacherProfile from "@/pages/teacher/TeacherProfile";
import TeacherLibrary from "@/pages/teacher/TeacherLibrary";
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
// Admin LMS pages
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
@@ -222,10 +223,11 @@ const App = () => (
</Route>
{/* Teacher routes */}
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
<Route element={<TeacherLayout />}>
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
<Route path="/teacher/courses" element={<TeacherCourses />} />
<Route path="/teacher/library" element={<TeacherLibrary />} />
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
@@ -233,9 +235,9 @@ const App = () => (
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
<Route path="/teacher/students" element={<TeacherStudents />} />
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />

View File

@@ -0,0 +1,235 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
import { API_BASE_URL } from "@/lib/api-client";
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
interface MaterialViewerProps {
material: ChapterMaterial | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onDownload?: (materialId: number, name: string) => void;
}
const typeLabels: Record<MaterialType, string> = {
pdf: "PDF Document",
document: "Document",
video: "Video",
audio: "Audio",
image: "Image",
link: "External Link",
article: "Article",
};
const typeIcons: Record<MaterialType, React.ReactNode> = {
pdf: <FileText className="h-4 w-4" />,
document: <FileText className="h-4 w-4" />,
video: <Video className="h-4 w-4" />,
audio: <Music className="h-4 w-4" />,
image: <Image className="h-4 w-4" />,
link: <Link2 className="h-4 w-4" />,
article: <FileText className="h-4 w-4" />,
};
function getContentUrl(materialId: number): string {
const token = localStorage.getItem("encoach_token") ?? "";
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
}
function isYouTubeUrl(url: string): boolean {
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
}
function getYouTubeEmbedUrl(url: string): string {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
);
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
}
function PdfViewer({ material }: { material: ChapterMaterial }) {
if (material.file_url) {
return (
<iframe
src={getContentUrl(material.id)}
className="w-full h-full min-h-[600px] rounded-lg border"
title={material.name}
/>
);
}
if (material.url) {
return (
<iframe
src={material.url}
className="w-full h-full min-h-[600px] rounded-lg border"
title={material.name}
/>
);
}
return <EmptyState message="No PDF file available." />;
}
function VideoViewer({ material }: { material: ChapterMaterial }) {
if (material.url && isYouTubeUrl(material.url)) {
return (
<iframe
src={getYouTubeEmbedUrl(material.url)}
className="w-full aspect-video rounded-lg border"
title={material.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
);
}
if (material.url) {
return (
<video
src={material.url}
controls
className="w-full rounded-lg border"
title={material.name}
>
Your browser does not support the video tag.
</video>
);
}
if (material.file_url) {
return (
<video
src={getContentUrl(material.id)}
controls
className="w-full rounded-lg border"
title={material.name}
>
Your browser does not support the video tag.
</video>
);
}
return <EmptyState message="No video source available." />;
}
function AudioViewer({ material }: { material: ChapterMaterial }) {
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
if (!src) return <EmptyState message="No audio source available." />;
return (
<div className="flex items-center justify-center py-12">
<audio src={src} controls className="w-full max-w-lg">
Your browser does not support the audio element.
</audio>
</div>
);
}
function ImageViewer({ material }: { material: ChapterMaterial }) {
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
if (!src) return <EmptyState message="No image available." />;
return (
<div className="flex items-center justify-center">
<img
src={src}
alt={material.name}
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
/>
</div>
);
}
function ArticleViewer({ material }: { material: ChapterMaterial }) {
if (!material.description) return <EmptyState message="No article content available." />;
return (
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
<div className="whitespace-pre-wrap">{material.description}</div>
</div>
);
}
function LinkViewer({ material }: { material: ChapterMaterial }) {
if (!material.url) return <EmptyState message="No URL available." />;
return (
<div className="space-y-4">
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
<Link2 className="h-5 w-5 text-primary shrink-0" />
<a
href={material.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline break-all flex-1"
>
{material.url}
</a>
<Button variant="outline" size="sm" asChild>
<a href={material.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" /> Open
</a>
</Button>
</div>
<iframe
src={material.url}
className="w-full min-h-[500px] rounded-lg border"
title={material.name}
sandbox="allow-scripts allow-same-origin"
/>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<FileText className="h-12 w-12 mb-3 opacity-40" />
<p>{message}</p>
</div>
);
}
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
pdf: PdfViewer,
document: PdfViewer,
video: VideoViewer,
audio: AudioViewer,
image: ImageViewer,
link: LinkViewer,
article: ArticleViewer,
};
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
if (!material) return null;
const Viewer = viewers[material.type] ?? ArticleViewer;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
<SheetHeader className="space-y-3 pb-4 border-b">
<div className="flex items-center gap-2">
{typeIcons[material.type]}
<Badge variant="outline">{typeLabels[material.type]}</Badge>
</div>
<SheetTitle className="text-lg">{material.name}</SheetTitle>
{material.description && material.type !== "article" && (
<p className="text-sm text-muted-foreground">{material.description}</p>
)}
<div className="flex gap-2">
{material.allow_download && material.file_url && onDownload && (
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
<Download className="h-4 w-4 mr-1" /> Download
</Button>
)}
{material.url && (
<Button variant="outline" size="sm" asChild>
<a href={material.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
</a>
</Button>
)}
</div>
</SheetHeader>
<div className="mt-6">
<Viewer material={material} />
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,143 @@
import { useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { useQuery } from "@tanstack/react-query";
import { taxonomyService } from "@/services/taxonomy.service";
import { lmsService } from "@/services/lms.service";
interface TaxonomyCascadeProps {
subjectId: string;
onSubjectChange: (id: string) => void;
domainId: string;
onDomainChange: (id: string) => void;
topicIds: number[];
onTopicIdsChange: (ids: number[]) => void;
objectiveIds: number[];
onObjectiveIdsChange: (ids: number[]) => void;
}
export function TaxonomyCascade({
subjectId, onSubjectChange,
domainId, onDomainChange,
topicIds, onTopicIdsChange,
objectiveIds, onObjectiveIdsChange,
}: TaxonomyCascadeProps) {
const { data: subjects } = useQuery({
queryKey: ["taxonomy", "subjects"],
queryFn: () => taxonomyService.listSubjects(),
});
const numericSubjectId = subjectId !== "none" ? Number(subjectId) : undefined;
const numericDomainId = domainId !== "none" ? Number(domainId) : undefined;
const { data: domains } = useQuery({
queryKey: ["taxonomy", "domains", numericSubjectId],
queryFn: () => taxonomyService.listDomains({ subject_id: numericSubjectId }),
enabled: !!numericSubjectId,
});
const { data: topics } = useQuery({
queryKey: ["taxonomy", "topics", numericDomainId],
queryFn: () => taxonomyService.listTopics({ domain_id: numericDomainId }),
enabled: !!numericDomainId,
});
const { data: objectivesData } = useQuery({
queryKey: ["learning-objectives", topicIds],
queryFn: () => lmsService.listLearningObjectives({ topic_ids: topicIds.join(",") }),
enabled: topicIds.length > 0,
});
const objectives = objectivesData?.items ?? [];
useEffect(() => {
if (subjectId === "none") {
if (domainId !== "none") onDomainChange("none");
if (topicIds.length > 0) onTopicIdsChange([]);
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
}
}, [subjectId]);
useEffect(() => {
if (domainId === "none") {
if (topicIds.length > 0) onTopicIdsChange([]);
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
}
}, [domainId]);
const toggleTopic = (id: number) => {
const next = topicIds.includes(id) ? topicIds.filter(t => t !== id) : [...topicIds, id];
onTopicIdsChange(next);
if (next.length === 0 && objectiveIds.length > 0) onObjectiveIdsChange([]);
};
const toggleObjective = (id: number) => {
onObjectiveIdsChange(
objectiveIds.includes(id) ? objectiveIds.filter(o => o !== id) : [...objectiveIds, id],
);
};
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Subject</Label>
<Select value={subjectId} onValueChange={(v) => { onSubjectChange(v); if (v === "none") { onDomainChange("none"); } }}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Select subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{(subjects ?? []).map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Domain</Label>
<Select value={domainId} onValueChange={onDomainChange} disabled={!numericSubjectId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder={numericSubjectId ? "Select domain" : "Select subject first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{(domains ?? []).map(d => <SelectItem key={d.id} value={String(d.id)}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{numericDomainId && (topics ?? []).length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Topics</Label>
<div className="flex flex-wrap gap-1.5 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
{(topics ?? []).map(t => (
<Badge
key={t.id}
variant={topicIds.includes(t.id) ? "default" : "outline"}
className="cursor-pointer select-none text-xs"
onClick={() => toggleTopic(t.id)}
>
{t.name}
</Badge>
))}
</div>
</div>
)}
{topicIds.length > 0 && objectives.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Learning Objectives</Label>
<div className="space-y-1 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
{objectives.map(o => (
<label key={o.id} className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={objectiveIds.includes(o.id)}
onCheckedChange={() => toggleObjective(o.id)}
/>
<span>{o.name}</span>
{o.bloom_level && <Badge variant="outline" className="text-[10px] h-4">{o.bloom_level}</Badge>}
</label>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, FileText,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2,
Megaphone, Wand2, Library,
} from "lucide-react";
const navGroups: NavGroup[] = [
@@ -11,6 +11,7 @@ const navGroups: NavGroup[] = [
items: [
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
{ title: "Resource Library", url: "/teacher/library", icon: Library },
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
],
},

View File

@@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
aria-describedby={props["aria-describedby"] ?? undefined}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,

View File

@@ -79,6 +79,7 @@ export const queryKeys = {
lms: {
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
course: (id: number) => ["lms", "courses", id] as const,
myCourses: ["lms", "my-courses"] as const,
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
@@ -155,6 +156,7 @@ export const queryKeys = {
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
},
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
chapter: (id: number) => ["chapters", "detail", id] as const,
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
@@ -211,6 +213,7 @@ export const queryKeys = {
studentProgress: {
all: ["student-progress"] as const,
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
detail: (id: number) => ["student-progress", "detail", id] as const,
},
libraryMedia: {
all: ["library-media"] as const,

View File

@@ -66,7 +66,10 @@ export function useGenerateTerms() {
const qc = useQueryClient();
return useMutation({
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all });
qc.invalidateQueries({ queryKey: queryKeys.academicYears.all });
},
});
}

View File

@@ -120,6 +120,19 @@ export function useUploadMaterial() {
});
}
export function useCreateMaterialFromResource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ chapterId, resourceId, name }: { chapterId: number; resourceId: number; name?: string }) =>
coursewareService.createMaterialFromResource(chapterId, resourceId, name),
onSuccess: (_, { chapterId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
},
});
}
export function useDeleteMaterial() {
const qc = useQueryClient();
return useMutation({
@@ -145,13 +158,24 @@ export function useMarkMaterialViewed() {
});
}
export function useCourseCompletion(courseId: number) {
return useQuery({
queryKey: queryKeys.courseCompletion(courseId),
queryFn: () => coursewareService.getCourseCompletion(courseId),
enabled: !!courseId,
});
}
export function useCompleteChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
onSuccess: (_, chapterId) => {
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
coursewareService.markChapterComplete(chapterId),
onSuccess: (_, { chapterId, courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.courseCompletion(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}

View File

@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { feesService } from "@/services/fees.service";
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
import type { PaginationParams } from "@/types";
export function useFeesPlans(params?: PaginationParams) {
export function useFeesPlans(params?: FeesPlanFilters) {
return useQuery({
queryKey: queryKeys.feesPlans.list(params),
queryFn: () => feesService.listFeesPlans(params),
@@ -18,7 +18,7 @@ export function useFeesPlan(id: number) {
});
}
export function useStudentFees(params?: PaginationParams) {
export function useStudentFees(params?: FeesPlanFilters) {
return useQuery({
queryKey: queryKeys.studentFees.list(params),
queryFn: () => feesService.listStudentFees(params),

View File

@@ -11,10 +11,11 @@ export function useGradebooks(params?: PaginationParams) {
});
}
export function useGradebookLines(params?: PaginationParams) {
export function useGradebookLines(params?: PaginationParams & { gradebook_id?: number }) {
return useQuery({
queryKey: queryKeys.gradebookLines.list(params),
queryFn: () => gradebookService.listGradebookLines(params),
enabled: params?.gradebook_id ? !!params.gradebook_id : true,
});
}

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { lmsService } from "@/services";
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest } from "@/types";
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest, EnrollStudentRequest, BulkEnrollRequest } from "@/types";
export function useCourses(params?: PaginationParams & { status?: string }) {
return useQuery({
@@ -26,6 +26,38 @@ export function useCreateCourse() {
});
}
export function useMyEnrolledCourses() {
return useQuery({
queryKey: queryKeys.lms.myCourses,
queryFn: () => lmsService.getMyEnrolledCourses(),
});
}
export function useEnrollStudent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ studentId, data }: { studentId: number; data: EnrollStudentRequest }) =>
lmsService.enrollStudent(studentId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "students"] });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}
export function useBulkEnroll() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, data }: { courseId: number; data: BulkEnrollRequest }) =>
lmsService.bulkEnrollInCourse(courseId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "students"] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
return useQuery({
queryKey: queryKeys.lms.students(params),

View File

@@ -1,11 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { studentProgressService } from "@/services/student-progress.service";
import type { PaginationParams } from "@/types";
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
export function useStudentProgressList(params?: PaginationParams) {
export function useStudentProgressList(params?: StudentProgressFilters) {
return useQuery({
queryKey: queryKeys.studentProgress.list(params),
queryFn: () => studentProgressService.listProgress(params),
});
}
export function useStudentProgressDetail(id: number | null | undefined) {
return useQuery({
queryKey: queryKeys.studentProgress.detail(id ?? 0),
queryFn: () => studentProgressService.getProgress(id as number),
enabled: !!id,
});
}

View File

@@ -78,24 +78,36 @@ type FormState = {
hide_assignee_details: boolean;
};
const emptyForm = (): FormState => ({
name: "",
exam_id: "",
entity_id: "",
start_date: "",
start_time: "09:00",
end_date: "",
end_time: "17:00",
assign_mode: "batch",
batch_ids: new Set(),
student_ids: new Set(),
full_length: true,
generate_different: false,
auto_release_results: false,
auto_start: false,
official_exam: false,
hide_assignee_details: false,
});
const _iso = (d: Date) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const emptyForm = (): FormState => {
const today = new Date();
const inOneWeek = new Date();
inOneWeek.setDate(today.getDate() + 7);
return {
name: "",
exam_id: "",
entity_id: "",
start_date: _iso(today),
start_time: "09:00",
end_date: _iso(inOneWeek),
end_time: "17:00",
assign_mode: "batch",
batch_ids: new Set(),
student_ids: new Set(),
full_length: true,
generate_different: false,
auto_release_results: false,
auto_start: false,
official_exam: false,
hide_assignee_details: false,
};
};
export default function AssignmentsPage() {
const [search, setSearch] = useState("");

View File

@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2, Users } from "lucide-react";
import { useBatches, useCourses } from "@/hooks/queries";
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
const [editOpen, setEditOpen] = useState(false);
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
const [studentsOpen, setStudentsOpen] = useState(false);
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
const { toast } = useToast();
const qc = useQueryClient();
const batchesQ = useBatches({ size: 200 });
const coursesQ = useCourses({ size: 200 });
const studentsQ = useStudents({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const courses = coursesQ.data?.items ?? [];
const allStudents = studentsQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setCreateOpen(false);
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
toast({ title: "Classroom created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setEditOpen(false);
toast({ title: "Classroom updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => lmsService.deleteBatch(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Deleted" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const addStudentsMut = useMutation({
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
lmsService.addStudentsToBatch(batchId, studentIds),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setAddStudentsOpen(false);
setSelectedAddIds([]);
toast({ title: `Added ${res.total} student(s)` });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const removeStudentMut = useMutation({
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
lmsService.removeStudentsFromBatch(batchId, studentIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Student removed from classroom" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const q = search.toLowerCase();
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
const filtered = batches.filter(
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
);
const loading = batchesQ.isLoading;
const currentBatch = batches.find((b) => b.id === studentsBatchId);
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
function openStudents(batchId: number) {
setStudentsBatchId(batchId);
setStudentsOpen(true);
}
function openEdit(b: typeof batches[0]) {
setEditBatch({
id: b.id,
name: b.name,
course_id: String(b.course_id || ""),
start_date: b.start_date,
end_date: b.end_date,
});
setEditOpen(true);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Classroom
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{batches.length} total</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Students</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10" />
<TableHead className="w-[140px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No classrooms found.
</TableCell>
</TableRow>
)}
{filtered.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
<TableCell>{b.capacity}</TableCell>
<TableCell className="text-sm">{b.start_date} {b.end_date}</TableCell>
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
<TableCell>{b.course_name || "—"}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
<Trash2 className="h-4 w-4" />
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-7 px-2"
onClick={() => openStudents(b.id)}
>
<Users className="h-3.5 w-3.5" />
{b.student_count} students
</Button>
</TableCell>
<TableCell className="text-sm">
{b.start_date && b.end_date
? `${b.start_date}${b.end_date}`
: "—"}
</TableCell>
<TableCell>
<Badge
variant={b.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{b.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
<UserPlus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => {
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
</Card>
)}
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
<div className="space-y-2">
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
</div>
</div>
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
<Button
disabled={createMut.isPending || !form.name}
onClick={() =>
createMut.mutate({
name: form.name,
course_id: Number(form.course_id) || undefined,
start_date: form.start_date || undefined,
end_date: form.end_date || undefined,
} as never)
}
>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
{editBatch && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editBatch.name}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select
value={editBatch.course_id}
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Start Date</Label>
<Input
type="date"
value={editBatch.start_date}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input
type="date"
value={editBatch.end_date}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editBatch?.name}
onClick={() => {
if (!editBatch) return;
updateMut.mutate({
id: editBatch.id,
data: {
name: editBatch.name,
course_id: Number(editBatch.course_id) || undefined,
start_date: editBatch.start_date || undefined,
end_date: editBatch.end_date || undefined,
},
});
}}
>
{updateMut.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Students Management Dialog */}
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
Classroom Students {currentBatch?.name}
</DialogTitle>
<DialogDescription>
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
</DialogDescription>
</DialogHeader>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{currentBatch?.student_ids?.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No students in this classroom yet.
</p>
) : (
allStudents
.filter((s) => batchStudentIds.has(s.id))
.map((s) => (
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive"
onClick={() => {
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
}}
>
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
</Button>
</div>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
<Button
onClick={() => {
setSelectedAddIds([]);
setAddStudentsOpen(true);
}}
>
<UserPlus className="h-4 w-4 mr-1" /> Add Students
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Students to Batch Dialog */}
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
<DialogDescription>
Select students to add to this classroom.
</DialogDescription>
</DialogHeader>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{availableStudents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
All students are already in this classroom.
</p>
) : (
availableStudents.map((s) => (
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
checked={selectedAddIds.includes(s.id)}
onCheckedChange={() =>
setSelectedAddIds((prev) =>
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
)
}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
</label>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
<Button
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
onClick={() => {
if (studentsBatchId)
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
}}
>
{addStudentsMut.isPending
? "Adding..."
: `Add ${selectedAddIds.length} Student(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -5,91 +5,280 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Building2, Trash2 } from "lucide-react";
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
import { entitiesService } from "@/services/entities.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
const ENTITY_TYPES = [
{ value: "corporate", label: "Corporate" },
{ value: "university", label: "University" },
{ value: "school", label: "School" },
{ value: "government", label: "Government" },
{ value: "freelance", label: "Freelance" },
];
export default function EntitiesPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", code: "" });
const [editOpen, setEditOpen] = useState(false);
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
const [form, setForm] = useState({ name: "", code: "", type: "" });
const { toast } = useToast();
const qc = useQueryClient();
const deptsQ = useDepartments({ size: 200 });
const createMut = useCreateDepartment();
const deleteMut = useDeleteDepartment();
const entitiesQ = useQuery({
queryKey: ["entities"],
queryFn: () => entitiesService.list({ size: 200 }),
});
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
const departments = Array.isArray(depts) ? depts : [];
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
const loading = deptsQ.isLoading;
const createMut = useMutation({
mutationFn: (data: { name: string; code?: string; type?: string }) =>
entitiesService.create(data as Partial<import("@/types").Entity>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
setCreateOpen(false);
setForm({ name: "", code: "", type: "" });
toast({ title: "Entity created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function handleCreate() {
createMut.mutate(
{ name: form.name, code: form.code || undefined },
{
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
entitiesService.update(id, data as Partial<import("@/types").Entity>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
setEditOpen(false);
toast({ title: "Entity updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => entitiesService.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
toast({ title: "Deleted" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const raw = entitiesQ.data;
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
if (!raw) return [];
if (Array.isArray(raw)) return raw as never[];
const r = raw as Record<string, unknown>;
const arr = (r.items ?? r.data ?? []) as never[];
return Array.isArray(arr) ? arr : [];
})();
const filtered = entities.filter(
(e) =>
e.name?.toLowerCase().includes(search.toLowerCase()) ||
e.code?.toLowerCase().includes(search.toLowerCase()),
);
const loading = entitiesQ.isLoading;
function openEdit(e: typeof entities[0]) {
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
setEditOpen(true);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
<p className="text-muted-foreground">Manage organizational departments.</p>
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Department
<Plus className="h-4 w-4 mr-1" /> Create Entity
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-4 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{entities.length} total</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.length === 0 && <p className="col-span-full text-center text-muted-foreground py-8">No departments found.</p>}
{filtered.map((d) => (
<Card key={d.id} className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
{d.name}
</CardTitle>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`Delete "${d.name}"?`)) deleteMut.mutate(d.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{d.code || "No code"}</p>
</CardContent>
</Card>
))}
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Type</TableHead>
<TableHead>Users</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No entities found.
</TableCell>
</TableRow>
)}
{filtered.map((e) => (
<TableRow key={e.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
{e.name}
</div>
</TableCell>
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{e.code}</code></TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">{e.type || "—"}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{e.user_count ?? 0}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />{e.role_count ?? 0}</Badge>
</TableCell>
<TableCell>
<Badge variant={e.active !== false ? "default" : "secondary"}>
{e.active !== false ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => {
if (window.confirm(`Delete entity "${e.name}"?`))
deleteMut.mutate(e.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Department name" /></div>
<div className="space-y-2"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
<div className="space-y-2">
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Organization name" />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} placeholder="ORG_CODE (auto-generated if empty)" />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={handleCreate}>
<Button
disabled={createMut.isPending || !form.name.trim()}
onClick={() =>
createMut.mutate({
name: form.name.trim(),
code: form.code.trim() || undefined,
type: form.type || undefined,
})
}
>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Entity</DialogTitle></DialogHeader>
{editEntity && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editEntity.name}
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, name: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input
value={editEntity.code}
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, code: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select
value={editEntity.type}
onValueChange={(v) => setEditEntity((prev) => prev ? { ...prev, type: v } : prev)}
>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editEntity?.name.trim()}
onClick={() => {
if (!editEntity) return;
updateMut.mutate({
id: editEntity.id,
data: { name: editEntity.name, code: editEntity.code, type: editEntity.type },
});
}}
>
{updateMut.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -436,12 +436,60 @@ export default function GenerationPage() {
const currentState = activeModule ? getModuleState(activeModule) : null;
/**
* Build the rich "persona context" that the backend AI controller uses to
* build its system prompt. Every parameter the user has configured in the
* UI (exam mode, structure, entity, rubric, grading system, access type,
* passage category/type, module, subject) flows through here so the
* generated questions actually reflect those choices.
*/
const buildPersonaContext = useCallback(
(mod: ModuleKey): Record<string, unknown> => {
const st = getModuleState(mod);
const entityIdNum = st.entity && st.entity !== "" && st.entity !== "none"
? Number(st.entity)
: undefined;
const entityObj = entityIdNum
? entities.find((e) => e.id === entityIdNum)
: undefined;
let rubricIdNum: number | undefined;
if (st.rubricId && st.rubricId.startsWith("rubric-")) {
const parsed = Number(st.rubricId.split("-")[1]);
if (!Number.isNaN(parsed)) rubricIdNum = parsed;
}
const rubricObj = rubricIdNum
? rubrics.find((r) => r.id === rubricIdNum)
: undefined;
const structureObj = selectedStructure;
return {
module: mod,
exam_mode: examMode,
exam_title: title || undefined,
exam_label: examLabel || undefined,
structure_name: structureObj?.name,
entity_id: entityIdNum,
entity_name: entityObj?.name,
rubric_id: rubricIdNum,
rubric_name: rubricObj?.name,
grading_system: st.gradingSystem || undefined,
access_type: st.accessType || undefined,
};
},
[getModuleState, entities, rubrics, selectedStructure, examMode, title, examLabel],
);
const generatePassageMut = useMutation({
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number }) =>
mutationFn: (params: { index: number; topic: string; difficulty: string; wordCount: number; category: string; passageType: string }) =>
generationService.generatePassage({
...buildPersonaContext(activeModule ?? "reading"),
topic: params.topic,
difficulty: params.difficulty,
word_count: params.wordCount,
category: params.category,
passage_type: params.passageType,
}),
onSuccess: (res, vars) => {
if (!activeModule) return;
@@ -463,19 +511,36 @@ export default function GenerationPage() {
types: string[];
typeCounts: Record<string, number>;
typeInstructions: Record<string, string>;
typeDifficulties?: Record<string, string>;
passageText: string;
difficulty: string;
}) => {
const mod = params.module === "level" || params.module === "industry" ? "reading" : params.module;
const totalCount = Object.values(params.typeCounts).reduce((s, n) => s + n, 0);
const st = getModuleState(params.module);
let category: string | undefined;
let passageType: string | undefined;
if (params.module === "listening") {
const sec = st.listeningSections[params.passageIndex];
category = sec?.category;
passageType = sec?.type;
} else {
const p = st.passages[params.passageIndex];
category = p?.category;
passageType = p?.type;
}
return generationService.generateExercises(mod as "reading" | "listening" | "writing" | "speaking", {
...buildPersonaContext(params.module),
passage_index: params.passageIndex,
exercise_types: params.types,
count_per_type: totalCount,
passage_text: params.passageText,
type_counts: params.typeCounts,
type_instructions: params.typeInstructions,
type_difficulties: params.typeDifficulties,
difficulty: params.difficulty,
category,
passage_type: passageType,
} as Record<string, unknown> & { passage_index: number; exercise_types: string[] });
},
onSuccess: (res, vars) => {
@@ -536,8 +601,18 @@ export default function GenerationPage() {
});
const generateWritingMut = useMutation({
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) =>
generationService.generateWritingInstructions({ topic: params.topic, difficulty: params.difficulty, task_type: "letter" }),
mutationFn: (params: { topic: string; difficulty: string; taskIndex: number }) => {
const st = getModuleState("writing");
const task = st.writingTasks[params.taskIndex];
return generationService.generateWritingInstructions({
...buildPersonaContext("writing"),
topic: params.topic,
difficulty: params.difficulty,
task_type: task?.type || "essay",
category: task?.category || undefined,
word_limit: task?.wordLimit,
});
},
onSuccess: (res, vars) => {
const st = getModuleState("writing");
const tasks = [...st.writingTasks];
@@ -551,8 +626,17 @@ export default function GenerationPage() {
});
const generateSpeakingMut = useMutation({
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => {
const st = getModuleState("speaking");
const part = st.speakingParts[params.partIndex];
return generationService.generateSpeakingScript({
...buildPersonaContext("speaking"),
topics: params.topics.filter(Boolean),
difficulty: params.difficulty,
part: part?.type || "speaking_1",
category: part?.category || undefined,
});
},
onSuccess: (res, vars) => {
const r = res as Record<string, unknown>;
if (r.error) {
@@ -874,7 +958,14 @@ export default function GenerationPage() {
}} />
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
onClick={() => {
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
generatePassageMut.mutate({
index: pi,
topic: passage.category || "",
difficulty: st.difficulty[0] || "B2",
wordCount: Number(passage.divider) || 300,
category: passage.category || "",
passageType: passage.type || "academic",
});
}}>
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
Generate
@@ -1126,7 +1217,13 @@ export default function GenerationPage() {
}} />
<div className="flex gap-2">
<Button size="sm" className="flex-1 text-xs" onClick={() => {
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
generationService.generateListeningContext({
...buildPersonaContext("listening"),
topic: sec.category || "general conversation",
section_type: sec.type,
category: sec.category || undefined,
difficulty: st.difficulty[0] || "B1",
})
.then((res) => {
const r = res as Record<string, unknown>;
const sections = [...st.listeningSections];
@@ -2680,9 +2777,11 @@ export default function GenerationPage() {
const types = wizardEnabledTypes.map(([key]) => key);
const typeCounts: Record<string, number> = {};
const typeInstructions: Record<string, string> = {};
const typeDifficulties: Record<string, string> = {};
for (const [key, cfg] of wizardEnabledTypes) {
typeCounts[key] = cfg.count;
if (cfg.instructions.trim()) typeInstructions[key] = cfg.instructions.trim();
if (cfg.difficulty) typeDifficulties[key] = cfg.difficulty;
}
const difficulty = wizardEnabledTypes[0]?.[1]?.difficulty || st.difficulty[0] || "B2";
generateExercisesMut.mutate({
@@ -2691,6 +2790,7 @@ export default function GenerationPage() {
types,
typeCounts,
typeInstructions,
typeDifficulties,
passageText: text,
difficulty,
});

View File

@@ -1,31 +1,119 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { CheckCircle, PenTool, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
Plus,
Search,
Trash2,
Loader2,
Sparkles,
Circle,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import { trainingService, type CefrLevel } from "@/services/training.service";
const grammarItems = [
{ rule: "Conditional Sentences (Type 2 & 3)", description: "If + past simple / past perfect for hypothetical situations", level: "B2", completed: true },
{ rule: "Passive Voice in Academic Writing", description: "Using passive constructions in formal reports", level: "B2", completed: false },
{ rule: "Relative Clauses", description: "Defining vs non-defining relative clauses", level: "B1", completed: true },
{ rule: "Subject-Verb Agreement", description: "Complex subjects with prepositional phrases", level: "B1", completed: false },
{ rule: "Modal Verbs for Speculation", description: "Must have, could have, might have + past participle", level: "C1", completed: false },
{ rule: "Reported Speech", description: "Tense changes and time references in indirect speech", level: "B2", completed: true },
];
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function GrammarPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = grammarItems.filter(g => g.completed).length;
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
const { toast } = useToast();
const qc = useQueryClient();
const [showCompleted, setShowCompleted] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
name: "",
description: "",
example: "",
level: "B1" as CefrLevel,
category: "general",
});
const listQ = useQuery({
queryKey: ["training-grammar", levelFilter, search],
queryFn: () =>
trainingService.listGrammar({
...(levelFilter !== "all" ? { level: levelFilter } : {}),
...(search ? { search } : {}),
}),
});
const items = listQ.data?.items ?? [];
const summary = listQ.data?.summary;
const filtered = showCompleted ? items : items.filter((g) => !g.completed);
const createMut = useMutation({
mutationFn: trainingService.createGrammar,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-grammar"] });
setCreateOpen(false);
setForm({
name: "",
description: "",
example: "",
level: "B1",
category: "general",
});
toast({ title: "Rule added" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to add rule";
toast({ title: msg, variant: "destructive" });
},
});
const delMut = useMutation({
mutationFn: trainingService.deleteGrammar,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-grammar"] });
toast({ title: "Rule deleted" });
},
});
const progressMut = useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
trainingService.setGrammarProgress(id, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-grammar"] }),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">
Manage the grammar-rule library and track mastery by CEFR level.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Rule
</Button>
</div>
<AiTipBanner context="grammar" variant="recommendation" />
@@ -36,31 +124,104 @@ export default function GrammarPage() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">{completed}/{grammarItems.length} completed</span>
<span className="text-sm text-muted-foreground">
{summary
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
: "—"}
</span>
</div>
</CardHeader>
<CardContent>
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search rules…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch
id="show-g"
checked={showCompleted}
onCheckedChange={setShowCompleted}
/>
<Label htmlFor="show-g" className="text-sm">
Show completed
</Label>
</div>
</div>
<div className="space-y-2">
{listQ.isLoading && (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</div>
)}
{!listQ.isLoading && filtered.length === 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="text-center text-muted-foreground py-8">
No grammar rules match.
</CardContent>
</Card>
)}
{filtered.map((g) => (
<Card key={g.rule} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{g.rule}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
</div>
<Card key={g.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: g.id, completed: !g.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
>
{g.completed ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">{g.name}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
{g.example && (
<p className="text-xs italic text-muted-foreground mt-0.5">
e.g. {g.example}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline">{g.level}</Badge>
<Badge variant="secondary" className="text-xs">
{g.category}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => delMut.mutate(g.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Badge variant="outline">{g.level}</Badge>
</CardContent>
</Card>
))}
@@ -70,25 +231,133 @@ export default function GrammarPage() {
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Practice passive voice transformations high exam frequency</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Review modal verbs for IELTS Writing Task 1</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Focus on complex sentence structures for C1 readiness</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Your conditional sentences are strong try advanced mixed conditionals</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Practice passive-voice transformations high exam frequency.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Review modal verbs for IELTS Writing Task 1.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Focus on complex sentences for C1 readiness.
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Study Plan</p>
<p className="text-sm text-muted-foreground">Based on your progress, complete Passive Voice and Subject-Verb Agreement this week. At your current pace, you'll finish all B2 grammar by April 15.</p>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
<Sparkles className="h-3 w-3" /> AI Study Plan
</p>
<p className="text-sm text-muted-foreground">
{summary.remaining > 0
? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
: "You've mastered every active rule in the library."}
</p>
</CardContent>
</Card>
)}
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Grammar Rule</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Rule name</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. Present Perfect vs Past Simple"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
placeholder="When and how this rule applies"
/>
</div>
<div className="space-y-2">
<Label>Example (optional)</Label>
<Textarea
value={form.example}
onChange={(e) => setForm((f) => ({ ...f, example: e.target.value }))}
placeholder="A sample sentence"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>CEFR level</Label>
<Select
value={form.level}
onValueChange={(v) =>
setForm((f) => ({ ...f, level: v as CefrLevel }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
(l) => (
<SelectItem key={l} value={l}>
{l}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) =>
setForm((f) => ({ ...f, category: e.target.value }))
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={
!form.name.trim() ||
!form.description.trim() ||
createMut.isPending
}
onClick={() => createMut.mutate(form)}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding
</>
) : (
"Add"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -89,7 +89,7 @@ export default function Login() {
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
type="text"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}

View File

@@ -1,57 +1,104 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Download } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Download, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
const payments = [
{ id: "PAY-001", corporate: "Acme Corp", manager: "John Admin", amount: 5000, currency: "USD", paid: true, date: "2025-01-15", type: "Corporate" },
{ id: "PAY-002", corporate: "Global Ltd", manager: "HR Global", amount: 8500, currency: "USD", paid: true, date: "2025-02-01", type: "Corporate" },
{ id: "PAY-003", corporate: "Tech Co", manager: "Tech Admin", amount: 2000, currency: "USD", paid: false, date: "2025-03-01", type: "Commission" },
];
const paymobOrders = [
{ id: "PMB-1001", status: "Paid", user: "Sarah Johnson", email: "sarah.j@email.com", amount: 199.00 },
{ id: "PMB-1002", status: "Pending", user: "Ahmed Hassan", email: "ahmed.h@email.com", amount: 149.00 },
{ id: "PMB-1003", status: "Paid", user: "Li Wei", email: "li.w@email.com", amount: 199.00 },
{ id: "PMB-1004", status: "Failed", user: "Emma Brown", email: "emma.b@email.com", amount: 99.00 },
];
import { paymentsService } from "@/services/payments.service";
export default function PaymentRecordPage() {
const { data, isLoading } = useQuery({
queryKey: ["payment-records"],
queryFn: () => paymentsService.list(),
});
const { data: paymobData } = useQuery({
queryKey: ["paymob-orders"],
queryFn: () => paymentsService.paymobOrders(),
});
const payments = data?.items ?? [];
const totals = data?.totals;
const handleExport = () => {
if (!payments.length) return;
const header = ["Ref", "Student", "Course", "Product", "Amount", "Currency", "State", "Paid", "Date"];
const rows = payments.map((p) => [
p.ref,
p.student_name,
p.course_name,
p.product_name,
p.amount.toFixed(2),
p.currency,
p.state,
p.paid ? "yes" : "no",
p.date,
]);
const csv = [header, ...rows].map((r) => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `payment-records-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
<p className="text-muted-foreground">Manage payments, commissions, and Paymob orders.</p>
<p className="text-muted-foreground">
Live student fees, invoices, and Paymob orders.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
<Dialog>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> New Payment</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Payment Record</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Corporate</Label><Select><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
<div className="space-y-2"><Label>Amount (USD)</Label><Input type="number" placeholder="5000" /></div>
<div className="space-y-2"><Label>Type</Label><Select><SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger><SelectContent><SelectItem value="corporate">Corporate</SelectItem><SelectItem value="commission">Commission</SelectItem></SelectContent></Select></div>
<Button className="w-full">Create</Button>
</div>
</DialogContent>
</Dialog>
<Button variant="outline" size="sm" onClick={handleExport} disabled={!payments.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
</div>
{totals && (
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Total records</p>
<p className="text-2xl font-bold">{totals.count}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Paid</p>
<p className="text-2xl font-bold text-green-600">{totals.paid}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Unpaid</p>
<p className="text-2xl font-bold text-red-600">{totals.unpaid}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Total amount</p>
<p className="text-2xl font-bold">${totals.amount.toLocaleString()}</p>
</CardContent>
</Card>
</div>
)}
<AiTipBanner context="payment-record" variant="recommendation" />
<Tabs defaultValue="payments">
@@ -67,20 +114,51 @@ export default function PaymentRecordPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
<TableHead>Ref</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Product</TableHead>
<TableHead>Amount</TableHead>
<TableHead>State</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
)}
{!isLoading && payments.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
No payment records yet.
</TableCell>
</TableRow>
)}
{payments.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs">{p.id}</TableCell>
<TableCell>{p.corporate}</TableCell>
<TableCell>{p.manager}</TableCell>
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
<TableCell>{p.date}</TableCell>
<TableCell className="font-mono text-xs">{p.ref}</TableCell>
<TableCell>{p.student_name || "—"}</TableCell>
<TableCell>{p.course_name || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{p.product_name || "—"}
</TableCell>
<TableCell className="font-semibold">
${p.amount.toLocaleString()} {p.currency}
</TableCell>
<TableCell>
<Badge variant="outline">{p.state}</Badge>
</TableCell>
<TableCell>
<Badge variant={p.paid ? "default" : "destructive"}>
{p.paid ? "Paid" : "Unpaid"}
</Badge>
</TableCell>
<TableCell>{p.date || "—"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
<TabsContent value="paymob" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymobOrders.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-mono text-xs">{o.id}</TableCell>
<TableCell><Badge variant={o.status === "Paid" ? "default" : o.status === "Pending" ? "secondary" : "destructive"}>{o.status}</Badge></TableCell>
<TableCell>{o.user}</TableCell>
<TableCell>{o.email}</TableCell>
<TableCell className="font-semibold">${o.amount.toFixed(2)}</TableCell>
<CardContent className="p-6">
{(paymobData?.items?.length ?? 0) === 0 ? (
<div className="text-center text-muted-foreground py-8">
{paymobData?.message ??
"Paymob integration is not yet wired. Orders will appear here once configured."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{paymobData!.items.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-mono text-xs">{o.id}</TableCell>
<TableCell>
<Badge
variant={
o.status === "Paid"
? "default"
: o.status === "Pending"
? "secondary"
: "destructive"
}
>
{o.status}
</Badge>
</TableCell>
<TableCell>{o.user}</TableCell>
<TableCell>{o.email}</TableCell>
<TableCell className="font-semibold">
${o.amount.toFixed(2)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>

View File

@@ -1,33 +1,142 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Copy } from "lucide-react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, Copy, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
const codes = [
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
];
const 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 },
];
import { useToast } from "@/hooks/use-toast";
import {
platformSettingsService,
type GradingConfig,
type PlatformPackage,
type RegistrationCode,
} from "@/services/platformSettings.service";
export default function SettingsPage() {
const { toast } = useToast();
const qc = useQueryClient();
// ── Codes ──────────────────────────────────────────────────────────
const codesQ = useQuery({
queryKey: ["codes"],
queryFn: () => platformSettingsService.listCodes(),
});
const codes: RegistrationCode[] = codesQ.data?.items ?? [];
const genMut = useMutation({
mutationFn: platformSettingsService.generateCodes,
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ["codes"] });
toast({ title: `Generated ${r.count} code(s)` });
},
onError: () =>
toast({ title: "Failed to generate codes", variant: "destructive" }),
});
const delCodeMut = useMutation({
mutationFn: platformSettingsService.deleteCode,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["codes"] });
toast({ title: "Code deleted" });
},
});
// ── Packages ───────────────────────────────────────────────────────
const pkgQ = useQuery({
queryKey: ["packages"],
queryFn: () => platformSettingsService.listPackages(),
});
const packages: PlatformPackage[] = pkgQ.data?.items ?? [];
const [pkgOpen, setPkgOpen] = useState(false);
const [pkgForm, setPkgForm] = useState<Omit<PlatformPackage, "id">>({
name: "",
price: 0,
duration: "1 month",
discount: 0,
});
const createPkgMut = useMutation({
mutationFn: platformSettingsService.createPackage,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["packages"] });
setPkgOpen(false);
setPkgForm({ name: "", price: 0, duration: "1 month", discount: 0 });
toast({ title: "Package created" });
},
});
const delPkgMut = useMutation({
mutationFn: platformSettingsService.deletePackage,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["packages"] });
toast({ title: "Package deleted" });
},
});
// ── Grading config ─────────────────────────────────────────────────
const gradingQ = useQuery({
queryKey: ["grading-config"],
queryFn: () => platformSettingsService.getGrading(),
});
const [grading, setGrading] = useState<GradingConfig>({
min_score: 0,
max_score: 9,
increment: 0.5,
});
useEffect(() => {
if (gradingQ.data) {
setGrading({
min_score: gradingQ.data.min_score,
max_score: gradingQ.data.max_score,
increment: gradingQ.data.increment,
});
}
}, [gradingQ.data]);
const saveGradingMut = useMutation({
mutationFn: platformSettingsService.setGrading,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["grading-config"] });
toast({ title: "Grading configuration saved" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to save";
toast({ title: msg, variant: "destructive" });
},
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Manage codes, packages, discounts, and grading system.</p>
<p className="text-muted-foreground">
Manage registration codes, packages, and the grading scale.
</p>
</div>
<Tabs defaultValue="codes">
@@ -37,26 +146,107 @@ export default function SettingsPage() {
<TabsTrigger value="grading">Grading System</TabsTrigger>
</TabsList>
{/* CODES */}
<TabsContent value="codes" className="mt-4 space-y-4">
<AiTipBanner context="settings-codes" variant="insight" />
<div className="flex gap-2">
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
<Button
size="sm"
disabled={genMut.isPending}
onClick={() =>
genMut.mutate({
count: 1,
code_type: "individual",
user_type: "student",
max_uses: 1,
})
}
>
{genMut.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-1" />
)}{" "}
Generate Single
</Button>
<Button
size="sm"
variant="outline"
disabled={genMut.isPending}
onClick={() =>
genMut.mutate({
count: 10,
code_type: "corporate",
user_type: "corporate",
max_uses: 50,
})
}
>
<Copy className="h-4 w-4 mr-1" /> Generate Batch (10)
</Button>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow><TableHead>Code</TableHead><TableHead>Type</TableHead><TableHead>Used</TableHead><TableHead>Created</TableHead><TableHead className="w-10"></TableHead></TableRow>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Type</TableHead>
<TableHead>User</TableHead>
<TableHead>Uses</TableHead>
<TableHead>State</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{codesQ.isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
)}
{!codesQ.isLoading && codes.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-8"
>
No codes yet. Click Generate Single to create one.
</TableCell>
</TableRow>
)}
{codes.map((c) => (
<TableRow key={c.code}>
<TableRow key={c.id}>
<TableCell className="font-mono text-xs">{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
<TableCell>{c.created}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
<TableCell>
<Badge variant="outline">{c.code_type}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{c.user_type}
</TableCell>
<TableCell className="text-sm">
{c.uses}/{c.max_uses}
</TableCell>
<TableCell>
<Badge variant={c.used ? "secondary" : "default"}>
{c.used ? "Used" : "Available"}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{c.created ? new Date(c.created).toLocaleDateString() : "—"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => delCodeMut.mutate(c.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -65,35 +255,186 @@ export default function SettingsPage() {
</Card>
</TabsContent>
{/* PACKAGES */}
<TabsContent value="packages" className="mt-4 space-y-4">
<AiTipBanner context="settings-packages" variant="recommendation" />
<div className="flex justify-end">
<Button size="sm" onClick={() => setPkgOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Package
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
{packages.map((p) => (
<Card key={p.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardHeader className="pb-3 flex-row items-start justify-between">
<CardTitle className="text-base">{p.name}</CardTitle>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => delPkgMut.mutate(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-2xl font-bold">${p.price}</p>
<p className="text-sm text-muted-foreground">{p.duration}</p>
{p.discount > 0 && <Badge variant="default">{p.discount}% OFF</Badge>}
{p.discount > 0 && (
<Badge variant="default">{p.discount}% OFF</Badge>
)}
</CardContent>
</Card>
))}
</div>
<Dialog open={pkgOpen} onOpenChange={setPkgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Package</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={pkgForm.name}
onChange={(e) =>
setPkgForm((f) => ({ ...f, name: e.target.value }))
}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Price (USD)</Label>
<Input
type="number"
value={pkgForm.price}
onChange={(e) =>
setPkgForm((f) => ({
...f,
price: Number(e.target.value),
}))
}
/>
</div>
<div className="space-y-2">
<Label>Discount %</Label>
<Input
type="number"
value={pkgForm.discount}
onChange={(e) =>
setPkgForm((f) => ({
...f,
discount: Number(e.target.value),
}))
}
/>
</div>
</div>
<div className="space-y-2">
<Label>Duration</Label>
<Select
value={pkgForm.duration}
onValueChange={(v) =>
setPkgForm((f) => ({ ...f, duration: v }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1 month">1 month</SelectItem>
<SelectItem value="3 months">3 months</SelectItem>
<SelectItem value="6 months">6 months</SelectItem>
<SelectItem value="12 months">12 months</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPkgOpen(false)}>
Cancel
</Button>
<Button
disabled={!pkgForm.name || createPkgMut.isPending}
onClick={() => createPkgMut.mutate(pkgForm)}
>
{createPkgMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
{/* GRADING */}
<TabsContent value="grading" className="mt-4 space-y-4">
<AiTipBanner context="settings-grading" variant="tip" />
<Card className="border-0 shadow-sm max-w-lg">
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="text-base">Scoring Scale</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Min Score</Label><Input type="number" defaultValue="0" /></div>
<div className="space-y-2"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
<div className="space-y-2">
<Label>Min Score</Label>
<Input
type="number"
value={grading.min_score}
onChange={(e) =>
setGrading((g) => ({
...g,
min_score: Number(e.target.value),
}))
}
/>
</div>
<div className="space-y-2">
<Label>Max Score</Label>
<Input
type="number"
value={grading.max_score}
onChange={(e) =>
setGrading((g) => ({
...g,
max_score: Number(e.target.value),
}))
}
/>
</div>
</div>
<div className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
<Button>Save Grading Configuration</Button>
<div className="space-y-2">
<Label>Score Increment</Label>
<Input
type="number"
step="0.5"
value={grading.increment}
onChange={(e) =>
setGrading((g) => ({
...g,
increment: Number(e.target.value),
}))
}
/>
</div>
<Button
disabled={saveGradingMut.isPending}
onClick={() => saveGradingMut.mutate(grading)}
>
{saveGradingMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving
</>
) : (
"Save Grading Configuration"
)}
</Button>
</CardContent>
</Card>
</TabsContent>

View File

@@ -33,7 +33,7 @@ export default function TicketsPage() {
}),
});
const tickets: Ticket[] = data?.data ?? [];
const tickets: Ticket[] = data?.items ?? data?.data ?? [];
const createMut = useMutation({
mutationFn: ticketsService.create,

View File

@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client";
function messageFromCreateError(err: unknown): string {
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
const e = (err.data as { error: unknown }).error;
if (e != null && String(e).trim()) return String(e);
}
if (err instanceof Error) return err.message;
return "Something went wrong.";
interface PlatformUser {
id: number;
name: string;
email: string;
login: string;
user_type: string;
active: boolean;
phone: string;
role_ids: number[];
roles: { id: number; name: string }[];
effective_permission_count: number;
}
function toastForCreateUserError(err: unknown): { title: string; description: string; variant?: "default" | "destructive" } {
const msg = messageFromCreateError(err).toLowerCase();
const isDuplicate =
msg.includes("already exists") ||
msg.includes("duplicate") ||
msg.includes("unique") ||
msg.includes("already registered");
if (isDuplicate) {
return {
title: "Email already in use",
description:
messageFromCreateError(err) +
" Try another address, or find the existing user in the Students or Teachers tab.",
variant: "destructive",
};
}
return { title: "Could not create user", description: messageFromCreateError(err), variant: "destructive" };
interface RoleInfo {
id: number;
name: string;
description: string;
assigned: boolean;
permission_count: number;
}
const TYPE_COLORS: Record<string, string> = {
admin: "bg-red-100 text-red-800 border-red-200",
teacher: "bg-blue-100 text-blue-800 border-blue-200",
student: "bg-green-100 text-green-800 border-green-200",
user: "bg-gray-100 text-gray-800 border-gray-200",
};
export default function UsersPage() {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
const [editUser, setEditUser] = useState<PlatformUser | null>(null);
const [rolesUser, setRolesUser] = useState<PlatformUser | null>(null);
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "" });
const { toast } = useToast();
const qc = useQueryClient();
const studentsQ = useStudents({ search: search || undefined, size: 200 });
const teachersQ = useTeachers({ search: search || undefined, size: 200 });
const createStudentMut = useCreateStudent();
const createTeacherMut = useCreateTeacher();
const students = studentsQ.data?.items ?? [];
const teachers = teachersQ.data?.items ?? [];
const deleteMut = useMutation({
mutationFn: async ({ type, id }: { type: "student" | "teacher"; id: number }) => {
if (type === "student") return lmsService.deleteStudent?.(id) ?? lmsService.updateStudent(id, { status: "inactive" });
return lmsService.updateTeacher?.(id, {}) ?? Promise.resolve();
const usersQ = useQuery({
queryKey: ["platform-users", search],
queryFn: async () => {
const params: Record<string, string | number> = { size: 200 };
if (search) params.search = search;
const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
return (res.items ?? res.data ?? []) as PlatformUser[];
},
});
const rolesQ = useQuery({
queryKey: ["user-roles-detail", rolesUser?.id],
queryFn: async () => {
if (!rolesUser) return null;
return api.get<{ roles: RoleInfo[]; effective_permissions: unknown[] }>(`/users/${rolesUser.id}/roles`);
},
enabled: !!rolesUser,
});
const createMut = useMutation({
mutationFn: (data: { name: string; email: string; password: string; phone?: string }) =>
api.post<{ data: PlatformUser }>("/users/create", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms"] });
toast({ title: "Done" });
qc.invalidateQueries({ queryKey: ["platform-users"] });
setCreateOpen(false);
setForm({ name: "", email: "", password: "", phone: "" });
toast({ title: "User created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function handleCreate() {
const email = form.email.trim();
const first_name = form.first_name.trim();
const last_name = form.last_name.trim();
const emailKey = email.toLowerCase();
const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
if (dupStudent || dupTeacher) {
toast({
title: "Email already in use",
description:
"That address matches someone already listed on this page (search may hide them). Use another email or clear search to find the user.",
variant: "destructive",
});
return;
}
const cb = {
onSuccess: () => {
setCreateOpen(false);
resetForm();
toast({ title: "User created successfully" });
},
onError: (err: unknown) => toast(toastForCreateUserError(err)),
};
if (form.role === "teacher") {
createTeacherMut.mutate(
{
first_name,
last_name,
email,
phone: form.phone?.trim() || undefined,
gender: (form.gender as "male" | "female") || undefined,
},
cb,
);
} else {
createStudentMut.mutate(
{
first_name,
last_name,
email,
phone: form.phone?.trim() || undefined,
gender: form.gender || undefined,
create_portal_user: true,
},
cb,
);
}
}
const updateMut = useMutation({
mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
api.patch<{ data: PlatformUser }>("/users/update", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
setEditUser(null);
toast({ title: "User updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function resetForm() {
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
}
const toggleRoleMut = useMutation({
mutationFn: ({ userId, roleId }: { userId: number; roleId: number }) =>
api.post<{ assigned: boolean }>(`/users/${userId}/roles/toggle`, { role_id: roleId }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
qc.invalidateQueries({ queryKey: ["user-roles-detail"] });
const verb = (res as { assigned: boolean }).assigned ? "assigned" : "removed";
toast({ title: `Role ${verb}` });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const users = usersQ.data ?? [];
const filtered = users.filter((u) => {
if (typeFilter !== "all" && u.user_type !== typeFilter) return false;
return true;
});
const typeCounts = {
all: users.length,
admin: users.filter((u) => u.user_type === "admin").length,
teacher: users.filter((u) => u.user_type === "teacher").length,
student: users.filter((u) => u.user_type === "student").length,
};
function exportCsv() {
const rows = [["Name", "Email", "Role", "Status"]];
students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
const csv = rows.map(r => r.join(",")).join("\n");
const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
users.forEach((u) =>
rows.push([
u.name,
u.email,
u.user_type,
u.roles.map((r) => r.name).join("; "),
String(u.effective_permission_count),
]),
);
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "users_export.csv";
a.download = "platform_users.csv";
a.click();
URL.revokeObjectURL(url);
}
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
const loading = studentsQ.isLoading || teachersQ.isLoading;
const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users across all roles.</p>
<p className="text-muted-foreground">
Manage all platform accounts admins, teachers, and students.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={exportCsv}>
@@ -159,177 +161,254 @@ export default function UsersPage() {
</div>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-3 items-center flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search users..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search by name..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Tabs value={typeFilter} onValueChange={setTypeFilter}>
<TabsList>
<TabsTrigger value="all">All ({typeCounts.all})</TabsTrigger>
<TabsTrigger value="admin">Admins ({typeCounts.admin})</TabsTrigger>
<TabsTrigger value="teacher">Teachers ({typeCounts.teacher})</TabsTrigger>
<TabsTrigger value="student">Students ({typeCounts.student})</TabsTrigger>
</TabsList>
</Tabs>
</div>
{loading ? (
{usersQ.isLoading ? (
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Tabs defaultValue="students">
<TabsList>
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
</TabsList>
<TabsContent value="students" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enrollment</TableHead>
<TableHead className="w-10" />
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Type</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No users found.
</TableCell>
</TableRow>
)}
{filtered.map((u) => {
const initials = u.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<TableRow key={u.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{initials}
</div>
<div>
<p className="text-sm font-medium">{u.name}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={`capitalize text-xs ${TYPE_COLORS[u.user_type] || TYPE_COLORS.user}`}>
{u.user_type}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.roles.length > 0
? u.roles.map((r) => (
<Badge key={r.id} variant="secondary" className="text-xs">
{r.name}
</Badge>
))
: <span className="text-xs text-muted-foreground">No roles</span>}
</div>
</TableCell>
<TableCell>
<span className="text-sm">{u.effective_permission_count}</span>
</TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "secondary"}>
{u.active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit user" onClick={() => setEditUser(u)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage roles" onClick={() => setRolesUser(u)}>
<UserCog className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
)}
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.email}</TableCell>
<TableCell>{s.phone || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>
<Badge variant={s.status === "active" ? "default" : "secondary"} className="capitalize">{s.status || "active"}</Badge>
</TableCell>
<TableCell>{s.enrollment_date || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
Deactivate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="teachers" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Department</TableHead>
<TableHead>Specialization</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{teachers.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>
)}
{teachers.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{t.email}</TableCell>
<TableCell>{t.phone || "—"}</TableCell>
<TableCell>{t.department_name || "—"}</TableCell>
<TableCell>{t.specialization || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
{/* Create User Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create New User</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Platform User</DialogTitle>
<DialogDescription>
Creates an internal Odoo user (res.users) with login access.
For students/teachers, use the LMS pages instead.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={form.first_name} onChange={(e) => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={form.last_name} onChange={(e) => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
</div>
<div className="space-y-2">
<Label>Full Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
<p className="text-xs text-muted-foreground">
Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
</p>
<Label>Email (login)</Label>
<Input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
<Label>Password</Label>
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Role</Label>
<Select value={form.role} onValueChange={(v) => setForm(f => ({ ...f, role: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={form.gender} onValueChange={(v) => setForm(f => ({ ...f, gender: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Phone (optional)</Label>
<Input value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
onClick={handleCreate}
disabled={createMut.isPending || !form.name.trim() || !form.email.trim()}
onClick={() => createMut.mutate({ name: form.name.trim(), email: form.email.trim(), password: form.password || "admin123", phone: form.phone.trim() || undefined })}
>
{isPending ? "Creating..." : "Create"}
{createMut.isPending ? "Creating..." : "Create User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={!!editUser} onOpenChange={(v) => { if (!v) setEditUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>Update profile for {editUser?.name}</DialogDescription>
</DialogHeader>
{editUser && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editUser.name}
onChange={(e) => setEditUser((u) => u ? { ...u, name: e.target.value } : null)}
/>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={editUser.email}
onChange={(e) => setEditUser((u) => u ? { ...u, email: e.target.value } : null)}
/>
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input
value={editUser.phone || ""}
onChange={(e) => setEditUser((u) => u ? { ...u, phone: e.target.value } : null)}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editUser?.name.trim()}
onClick={() => {
if (!editUser) return;
updateMut.mutate({
id: editUser.id,
name: editUser.name.trim(),
email: editUser.email.trim(),
phone: editUser.phone?.trim() || undefined,
});
}}
>
{updateMut.isPending ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Manage Roles Dialog */}
<Dialog open={!!rolesUser} onOpenChange={(v) => { if (!v) setRolesUser(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Manage Roles {rolesUser?.name}
</div>
</DialogTitle>
<DialogDescription>Toggle roles on/off for this user.</DialogDescription>
</DialogHeader>
{rolesQ.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : rolesData?.roles ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{rolesData.roles.map((r) => (
<label
key={r.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
r.assigned ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
}`}
>
<Checkbox
checked={r.assigned}
onCheckedChange={() => {
if (rolesUser) toggleRoleMut.mutate({ userId: rolesUser.id, roleId: r.id });
}}
/>
<div className="flex-1">
<p className="text-sm font-medium">{r.name}</p>
{r.description && <p className="text-xs text-muted-foreground">{r.description}</p>}
</div>
<Badge variant="outline" className="text-xs shrink-0">
{r.permission_count} perms
</Badge>
</label>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No roles available.</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setRolesUser(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,96 +1,389 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { CheckCircle, BookA, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
Plus,
Search,
Trash2,
Loader2,
Sparkles,
Circle,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import {
trainingService,
type VocabItem,
type CefrLevel,
} from "@/services/training.service";
const vocabItems = [
{ word: "Ubiquitous", meaning: "Present, appearing, or found everywhere", level: "C1", completed: true },
{ word: "Pragmatic", meaning: "Dealing with things sensibly and realistically", level: "B2", completed: true },
{ word: "Eloquent", meaning: "Fluent or persuasive in speaking or writing", level: "C1", completed: false },
{ word: "Meticulous", meaning: "Showing great attention to detail", level: "B2", completed: false },
{ word: "Ambiguous", meaning: "Open to more than one interpretation", level: "B2", completed: false },
{ word: "Coherent", meaning: "Logical and consistent", level: "B1", completed: false },
{ word: "Versatile", meaning: "Able to adapt to many different functions", level: "B2", completed: true },
{ word: "Concise", meaning: "Giving a lot of information in few words", level: "B1", completed: false },
];
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function VocabularyPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = vocabItems.filter(v => v.completed).length;
const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
const { toast } = useToast();
const qc = useQueryClient();
const [showCompleted, setShowCompleted] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
word: "",
meaning: "",
example_sentence: "",
level: "B1" as CefrLevel,
part_of_speech: "noun" as VocabItem["part_of_speech"],
category: "general",
});
const listQ = useQuery({
queryKey: ["training-vocab", levelFilter, search],
queryFn: () =>
trainingService.listVocab({
...(levelFilter !== "all" ? { level: levelFilter } : {}),
...(search ? { search } : {}),
}),
});
const items = listQ.data?.items ?? [];
const summary = listQ.data?.summary;
const filtered = showCompleted ? items : items.filter((v) => !v.completed);
const createMut = useMutation({
mutationFn: trainingService.createVocab,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-vocab"] });
setCreateOpen(false);
setForm({
word: "",
meaning: "",
example_sentence: "",
level: "B1",
part_of_speech: "noun",
category: "general",
});
toast({ title: "Word added" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to add word";
toast({ title: msg, variant: "destructive" });
},
});
const delMut = useMutation({
mutationFn: trainingService.deleteVocab,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-vocab"] });
toast({ title: "Word deleted" });
},
});
const progressMut = useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
trainingService.setVocabProgress(id, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-vocab"] }),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
<p className="text-muted-foreground">
Build and manage the vocabulary library track completion by CEFR level.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Word
</Button>
</div>
<AiTipBanner context="vocabulary" variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
{/* Summary */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">{completed}/{vocabItems.length} completed</span>
<span className="text-sm text-muted-foreground">
{summary
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
: "—"}
</span>
</div>
</CardHeader>
<CardContent>
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search word or meaning…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">
Show completed
</Label>
</div>
</div>
{/* List */}
<div className="space-y-2">
{listQ.isLoading && (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</div>
)}
{!listQ.isLoading && filtered.length === 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="text-center text-muted-foreground py-8">
No vocabulary items match.
</CardContent>
</Card>
)}
{filtered.map((v) => (
<Card key={v.word} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{v.word}</p>
<p className="text-xs text-muted-foreground">{v.meaning}</p>
</div>
<Card key={v.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: v.id, completed: !v.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
aria-label={v.completed ? "Mark incomplete" : "Mark complete"}
>
{v.completed ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">{v.word}</p>
<p className="text-xs text-muted-foreground truncate">{v.meaning}</p>
{v.example_sentence && (
<p className="text-xs italic text-muted-foreground mt-0.5 truncate">
{v.example_sentence}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline">{v.level}</Badge>
<Badge variant="secondary" className="text-xs">
{v.part_of_speech}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => delMut.mutate(v.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Badge variant="outline">{v.level}</Badge>
</CardContent>
</Card>
))}
</div>
</div>
{/* AI panel */}
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Learn "coherent" + "concise" together they share academic writing context</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Review C1-level vocabulary for IELTS Task 2</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Focus on collocations with 'make' and 'do'</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Try using "meticulous" in your next writing practice</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Pair <b>coherent</b> + <b>concise</b> they share academic-writing
contexts.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Review C1 words for IELTS Writing Task 2.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Try using <b>meticulous</b> in your next essay.
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Vocabulary Goal</p>
<p className="text-sm text-muted-foreground">At 2 words/day, you'll complete this set by March 15. AI recommends adding 10 more domain-specific words from your upcoming exam topics.</p>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
<Sparkles className="h-3 w-3" /> AI Vocabulary Goal
</p>
<p className="text-sm text-muted-foreground">
{summary.remaining > 0
? `At 2 words/day, you'll complete the remaining ${summary.remaining} items in about ${Math.ceil(summary.remaining / 2)} days.`
: "You've completed every active word — try adding more to keep challenging yourself."}
</p>
</CardContent>
</Card>
)}
</div>
</div>
{/* Create dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Vocabulary Word</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Word</Label>
<Input
value={form.word}
onChange={(e) => setForm((f) => ({ ...f, word: e.target.value }))}
placeholder="e.g. Ubiquitous"
/>
</div>
<div className="space-y-2">
<Label>Meaning</Label>
<Textarea
value={form.meaning}
onChange={(e) => setForm((f) => ({ ...f, meaning: e.target.value }))}
placeholder="Plain-English definition"
/>
</div>
<div className="space-y-2">
<Label>Example sentence (optional)</Label>
<Textarea
value={form.example_sentence}
onChange={(e) =>
setForm((f) => ({ ...f, example_sentence: e.target.value }))
}
placeholder="Use the word in context"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>CEFR level</Label>
<Select
value={form.level}
onValueChange={(v) =>
setForm((f) => ({ ...f, level: v as CefrLevel }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
(l) => (
<SelectItem key={l} value={l}>
{l}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Part of speech</Label>
<Select
value={form.part_of_speech}
onValueChange={(v) =>
setForm((f) => ({
...f,
part_of_speech: v as VocabItem["part_of_speech"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="noun">noun</SelectItem>
<SelectItem value="verb">verb</SelectItem>
<SelectItem value="adjective">adjective</SelectItem>
<SelectItem value="adverb">adverb</SelectItem>
<SelectItem value="phrase">phrase</SelectItem>
<SelectItem value="other">other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={!form.word.trim() || !form.meaning.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding
</>
) : (
"Add"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -87,7 +87,10 @@ export default function AcademicYearManager() {
<Button><Plus className="mr-2 h-4 w-4" /> Create Academic Year</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Academic Year</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Academic Year</DialogTitle>
<DialogDescription>Define the academic year range and term structure.</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Name</Label>

View File

@@ -256,13 +256,14 @@ export default function AdminActivities() {
Cancel
</Button>
<Button
disabled={createType.isPending}
disabled={createType.isPending || !typeName.trim()}
onClick={() =>
createType.mutate(
{ name: typeName },
{ name: typeName.trim() },
{
onSuccess: () => {
setTypeOpen(false);
setTypeName("");
toast({ title: "Created successfully" });
},
onError: (err: Error) =>

View File

@@ -1,50 +1,480 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useCourses, useCreateCourse } from "@/hooks/queries";
import { lmsService } from "@/services";
import { useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react";
import { Link } from "react-router-dom";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import type { Course, CourseCreateRequest } from "@/types";
import type { ResourceTag } from "@/types/adaptive";
const DIFFICULTY_OPTIONS = [
{ value: "beginner", label: "Beginner" },
{ value: "intermediate", label: "Intermediate" },
{ value: "advanced", label: "Advanced" },
];
const CEFR_OPTIONS = [
{ value: "pre_a1", label: "Pre-A1" },
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
{ value: "c1", label: "C1" },
{ value: "c2", label: "C2" },
];
interface CourseFormData {
title: string;
code: string;
description: string;
max_capacity: number;
encoach_subject_id: number | null;
topic_ids: number[];
learning_objective_ids: number[];
tag_ids: number[];
difficulty_level: string;
cefr_level: string;
}
const emptyForm: CourseFormData = {
title: "",
code: "",
description: "",
max_capacity: 30,
encoach_subject_id: null,
topic_ids: [],
learning_objective_ids: [],
tag_ids: [],
difficulty_level: "",
cefr_level: "",
};
function CourseFormDialog({
open,
onOpenChange,
initialData,
courseId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initialData?: CourseFormData;
courseId?: number;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const createMut = useCreateCourse();
const [form, setForm] = useState<CourseFormData>(initialData ?? emptyForm);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setForm(initialData ?? emptyForm);
}
}, [open, initialData]);
const { data: subjects = [] } = useQuery({
queryKey: ["taxonomy", "subjects"],
queryFn: () => taxonomyService.listSubjects(),
});
const { data: topicsData } = useQuery({
queryKey: ["taxonomy", "topics", form.encoach_subject_id],
queryFn: () =>
form.encoach_subject_id
? taxonomyService.listTopics({ subject_id: form.encoach_subject_id })
: Promise.resolve([]),
enabled: !!form.encoach_subject_id,
});
const topics = topicsData ?? [];
const { data: objectivesData } = useQuery({
queryKey: ["lms", "objectives", form.topic_ids.join(",")],
queryFn: () =>
form.topic_ids.length > 0
? lmsService.listLearningObjectives({ topic_ids: form.topic_ids.join(",") })
: Promise.resolve({ items: [], total: 0 }),
enabled: form.topic_ids.length > 0,
});
const objectives = objectivesData?.items ?? [];
const { data: allTags = [] } = useQuery({
queryKey: ["resource-tags"],
queryFn: () => resourcesService.listTags(),
});
function handleSubjectChange(val: string) {
const sid = val === "none" ? null : Number(val);
setForm((f) => ({
...f,
encoach_subject_id: sid,
topic_ids: [],
learning_objective_ids: [],
}));
}
function toggleTopic(id: number) {
setForm((f) => {
const next = f.topic_ids.includes(id)
? f.topic_ids.filter((t) => t !== id)
: [...f.topic_ids, id];
const validObjectiveTopics = new Set(
topics.filter((t) => next.includes(t.id)).flatMap((t) => [t.id])
);
return {
...f,
topic_ids: next,
learning_objective_ids: f.learning_objective_ids.filter((oid) =>
objectives.some(
(o) => o.id === oid && validObjectiveTopics.has(o.topic_id)
)
),
};
});
}
function toggleObjective(id: number) {
setForm((f) => ({
...f,
learning_objective_ids: f.learning_objective_ids.includes(id)
? f.learning_objective_ids.filter((o) => o !== id)
: [...f.learning_objective_ids, id],
}));
}
function toggleTag(id: number) {
setForm((f) => ({
...f,
tag_ids: f.tag_ids.includes(id)
? f.tag_ids.filter((t) => t !== id)
: [...f.tag_ids, id],
}));
}
async function handleSave() {
if (!form.title.trim()) return;
const payload: Partial<CourseCreateRequest> = {
title: form.title.trim(),
code:
form.code.trim() ||
form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16),
description: form.description,
max_capacity: form.max_capacity,
encoach_subject_id: form.encoach_subject_id ?? undefined,
topic_ids: form.topic_ids,
learning_objective_ids: form.learning_objective_ids,
tag_ids: form.tag_ids,
difficulty_level: (form.difficulty_level as CourseCreateRequest["difficulty_level"]) || undefined,
cefr_level: form.cefr_level || undefined,
};
if (courseId) {
setSaving(true);
try {
await lmsService.updateCourse(courseId, payload);
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast({ title: "Course updated" });
onOpenChange(false);
} catch (e: unknown) {
toast({ title: "Error", description: String(e instanceof Error ? e.message : e), variant: "destructive" });
}
setSaving(false);
} else {
createMut.mutate(payload as CourseCreateRequest, {
onSuccess: () => {
onOpenChange(false);
toast({ title: "Course created" });
},
onError: (e) =>
toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
});
}
}
const isPending = saving || createMut.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{courseId ? "Edit Course" : "Create New Course"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="e.g. IELTS Academic Preparation"
/>
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
placeholder="Auto-generated if empty"
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
rows={2}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Max Capacity</Label>
<Input
type="number"
value={form.max_capacity}
onChange={(e) => setForm((f) => ({ ...f, max_capacity: Number(e.target.value) || 30 }))}
/>
</div>
<div className="space-y-2">
<Label>Difficulty</Label>
<Select
value={form.difficulty_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{DIFFICULTY_OPTIONS.map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>CEFR Level</Label>
<Select
value={form.cefr_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
>
<SelectTrigger><SelectValue placeholder="Select..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{CEFR_OPTIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Select
value={form.encoach_subject_id ? String(form.encoach_subject_id) : "none"}
onValueChange={handleSubjectChange}
>
<SelectTrigger><SelectValue placeholder="Select subject..." /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{form.encoach_subject_id && topics.length > 0 && (
<div className="space-y-2">
<Label>Topics ({form.topic_ids.length} selected)</Label>
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
{topics.map((t) => (
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
<Checkbox
checked={form.topic_ids.includes(t.id)}
onCheckedChange={() => toggleTopic(t.id)}
/>
<span>{t.name}</span>
<span className="text-xs text-muted-foreground ml-auto">{t.domain_name}</span>
</label>
))}
</div>
</div>
)}
{form.topic_ids.length > 0 && objectives.length > 0 && (
<div className="space-y-2">
<Label>Learning Objectives ({form.learning_objective_ids.length} selected)</Label>
<div className="border rounded-md p-3 max-h-[140px] overflow-y-auto space-y-1">
{objectives.map((o) => (
<label key={o.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1 rounded">
<Checkbox
checked={form.learning_objective_ids.includes(o.id)}
onCheckedChange={() => toggleObjective(o.id)}
/>
<span>{o.name}</span>
{o.bloom_level && (
<Badge variant="outline" className="text-[10px] ml-auto capitalize">
{o.bloom_level}
</Badge>
)}
</label>
))}
</div>
</div>
)}
{allTags.length > 0 && (
<div className="space-y-2">
<Label>Tags</Label>
<div className="flex flex-wrap gap-2">
{allTags.map((t: ResourceTag) => (
<Badge
key={t.id}
variant={form.tag_ids.includes(t.id) ? "default" : "outline"}
className="cursor-pointer select-none transition-colors"
style={
form.tag_ids.includes(t.id)
? { backgroundColor: t.color, borderColor: t.color, color: "#fff" }
: { borderColor: t.color, color: t.color }
}
onClick={() => toggleTag(t.id)}
>
{t.name}
</Badge>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={isPending || !form.title.trim()}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{courseId ? "Save Changes" : "Create Course"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function courseToFormData(c: Course): CourseFormData {
return {
title: c.title,
code: c.code,
description: c.description,
max_capacity: c.max_capacity,
encoach_subject_id: c.encoach_subject_id ?? null,
topic_ids: c.topic_ids ?? [],
learning_objective_ids: c.learning_objective_ids ?? [],
tag_ids: c.tag_ids ?? [],
difficulty_level: c.difficulty_level ?? "",
cefr_level: c.cefr_level ?? "",
};
}
export default function AdminCourses() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", code: "", description: "", max_capacity: 30 });
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
const [enrollTab, setEnrollTab] = useState<"students" | "classroom">("students");
const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null);
const { data: coursesData, isLoading } = useCourses();
const createMut = useCreateCourse();
const { data: studentsData } = useStudents({ size: 200 });
const { data: batchesData } = useBatches({ size: 200 });
const enrollMut = useBulkEnroll();
const qc = useQueryClient();
const { toast } = useToast();
const courses = coursesData?.items ?? [];
const allStudents = studentsData?.items ?? [];
const allBatches = batchesData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
if (isLoading)
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
const filtered = courses.filter(c => c.title.toLowerCase().includes(search.toLowerCase()));
const filtered = courses.filter((c) =>
c.title.toLowerCase().includes(search.toLowerCase())
);
function handleCreate() {
if (!form.title.trim()) return;
createMut.mutate(
{ title: form.title.trim(), code: form.code.trim() || form.title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16), description: form.description, max_capacity: form.max_capacity },
{
onSuccess: () => {
setCreateOpen(false);
setForm({ title: "", code: "", description: "", max_capacity: 30 });
toast({ title: "Course created" });
function openEnrollDialog(courseId: number) {
setEnrollCourseId(courseId);
setSelectedStudentIds([]);
setSelectedBatchId(null);
setEnrollTab("students");
setEnrollOpen(true);
}
function handleEnroll() {
if (!enrollCourseId) return;
if (enrollTab === "classroom" && selectedBatchId) {
enrollMut.mutate(
{ courseId: enrollCourseId, data: { batch_id: selectedBatchId } },
{
onSuccess: (res) => {
toast({ title: `Enrolled ${res.total_enrolled} student(s) from classroom` });
setEnrollOpen(false);
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
},
onError: (e) =>
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
},
onError: (e) => toast({ title: "Error", description: String(e.message || e), variant: "destructive" }),
}
);
} else if (enrollTab === "students" && selectedStudentIds.length > 0) {
enrollMut.mutate(
{ courseId: enrollCourseId, data: { student_ids: selectedStudentIds } },
{
onSuccess: (res) => {
toast({ title: `Enrolled ${res.total_enrolled} student(s)` });
setEnrollOpen(false);
},
onError: (e) =>
toast({ title: "Enrollment failed", description: String(e.message || e), variant: "destructive" }),
},
);
}
}
function toggleStudentSelection(sid: number) {
setSelectedStudentIds((prev) =>
prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid]
);
}
const canEnroll =
(enrollTab === "students" && selectedStudentIds.length > 0) ||
(enrollTab === "classroom" && selectedBatchId !== null);
const enrollLabel =
enrollTab === "classroom"
? `Enroll Classroom${selectedBatchId ? ` (${allBatches.find((b) => b.id === selectedBatchId)?.student_count ?? 0} students)` : ""}`
: `Enroll ${selectedStudentIds.length} Student(s)`;
async function handleDelete(id: number, title: string) {
if (!window.confirm(`Delete course "${title}"?`)) return;
try {
@@ -60,47 +490,268 @@ export default function AdminCourses() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold">Courses</h1><p className="text-muted-foreground">Manage all platform courses.</p></div>
<div>
<h1 className="text-2xl font-bold">Courses</h1>
<p className="text-muted-foreground">Manage all platform courses.</p>
</div>
<div className="flex gap-2">
<AiCreationAssistant type="course" />
<Button onClick={() => setCreateOpen(true)}><Plus className="mr-2 h-4 w-4" />New Course</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Course
</Button>
</div>
</div>
<AiTipBanner context="admin-courses" variant="recommendation" />
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search courses..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
<Card><CardContent className="pt-6">
<Table>
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Code</TableHead><TableHead>Level</TableHead><TableHead>Enrolled / Capacity</TableHead><TableHead>Status</TableHead><TableHead className="w-[60px]" /></TableRow></TableHeader>
<TableBody>
{filtered.map(c => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell>{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.level}</Badge></TableCell>
<TableCell>{c.enrolled} / {c.max_capacity}</TableCell>
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => handleDelete(c.id, c.title)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && <TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No courses found.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent></Card>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search courses..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((c) => (
<TableRow key={c.id}>
<TableCell>
<div>
<span className="font-medium">{c.title}</span>
<span className="text-xs text-muted-foreground ml-2">{c.code}</span>
</div>
{c.topic_names && c.topic_names.length > 0 && (
<div className="text-xs text-muted-foreground mt-0.5">
{c.topic_names.slice(0, 3).join(", ")}
{c.topic_names.length > 3 && ` +${c.topic_names.length - 3}`}
</div>
)}
</TableCell>
<TableCell>
<div className="space-y-1">
{c.encoach_subject_name && (
<div className="flex items-center gap-1 text-xs">
<BookOpen className="h-3 w-3" />
{c.encoach_subject_name}
</div>
)}
<div className="flex flex-wrap gap-1">
{(c.tags ?? []).slice(0, 3).map((t) => (
<Badge
key={t.id}
variant="outline"
className="text-[10px] px-1.5 py-0"
style={{ borderColor: t.color, color: t.color }}
>
{t.name}
</Badge>
))}
</div>
</div>
</TableCell>
<TableCell>
{c.difficulty_level && (
<Badge variant="outline" className="capitalize text-xs">
{c.difficulty_level}
</Badge>
)}
{c.cefr_level && (
<Badge variant="secondary" className="ml-1 text-xs uppercase">
{c.cefr_level}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
{c.chapter_count ?? 0}
{(c.objective_count ?? 0) > 0 && (
<span className="text-xs text-muted-foreground ml-1">
<Target className="inline h-3 w-3 mr-0.5" />
{c.objective_count}
</span>
)}
</div>
</TableCell>
<TableCell>
{c.enrolled} / {c.max_capacity}
</TableCell>
<TableCell>
<Badge
variant={c.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{c.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
title="Edit course"
onClick={() => setEditingCourse(c)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Enroll students"
onClick={() => openEnrollDialog(c.id)}
>
<UserPlus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" title="Assign exam" asChild>
<Link to={`/admin/generation?course_id=${c.id}`}>
<FileEdit className="h-4 w-4" />
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(c.id, c.title)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No courses found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CourseFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
/>
{editingCourse && (
<CourseFormDialog
open={!!editingCourse}
onOpenChange={(open) => {
if (!open) setEditingCourse(null);
}}
initialData={courseToFormData(editingCourse)}
courseId={editingCourse.id}
/>
)}
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Enroll Students</DialogTitle>
<DialogDescription>
Enroll in: <strong>{courses.find((c) => c.id === enrollCourseId)?.title}</strong>
</DialogDescription>
</DialogHeader>
<Tabs value={enrollTab} onValueChange={(v) => setEnrollTab(v as "students" | "classroom")}>
<TabsList className="w-full">
<TabsTrigger value="students" className="flex-1 gap-1.5">
<UserPlus className="h-3.5 w-3.5" /> Individual Students
</TabsTrigger>
<TabsTrigger value="classroom" className="flex-1 gap-1.5">
<GraduationCap className="h-3.5 w-3.5" /> By Classroom
</TabsTrigger>
</TabsList>
<TabsContent value="students" className="mt-3">
<div className="max-h-[320px] overflow-y-auto space-y-1">
{allStudents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No students found.</p>
) : (
allStudents.map((s) => (
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
checked={selectedStudentIds.includes(s.id)}
onCheckedChange={() => toggleStudentSelection(s.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
</label>
))
)}
</div>
</TabsContent>
<TabsContent value="classroom" className="mt-3">
<div className="max-h-[320px] overflow-y-auto space-y-2">
{allBatches.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No classrooms found. Create one on the Classrooms page first.
</p>
) : (
allBatches.map((b) => (
<label
key={b.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedBatchId === b.id
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50"
}`}
onClick={() => setSelectedBatchId(selectedBatchId === b.id ? null : b.id)}
>
<input
type="radio"
name="batch"
checked={selectedBatchId === b.id}
onChange={() => setSelectedBatchId(b.id)}
className="accent-primary"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{b.name}</p>
<p className="text-xs text-muted-foreground">
{b.course_name && <span>{b.course_name} · </span>}
{b.start_date && <span>{b.start_date} {b.end_date}</span>}
</p>
</div>
<Badge variant="secondary" className="shrink-0">
<Users className="h-3 w-3 mr-1" />{b.student_count} students
</Badge>
</label>
))
)}
</div>
{selectedBatchId && (
<p className="text-xs text-muted-foreground mt-2">
All students in this classroom will be enrolled in the course with the classroom linked.
</p>
)}
</TabsContent>
</Tabs>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create New Course</DialogTitle></DialogHeader>
<div className="space-y-4 py-2">
<div><Label>Title *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Academic Preparation" /></div>
<div><Label>Code</Label><Input value={form.code} onChange={e => setForm(f => ({ ...f, code: e.target.value }))} placeholder="Auto-generated if empty" /></div>
<div><Label>Description</Label><Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} /></div>
<div><Label>Max Capacity</Label><Input type="number" value={form.max_capacity} onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={createMut.isPending}>{createMut.isPending ? "Creating..." : "Create Course"}</Button>
<Button variant="outline" onClick={() => setEnrollOpen(false)}>Cancel</Button>
<Button onClick={handleEnroll} disabled={enrollMut.isPending || !canEnroll}>
{enrollMut.isPending ? "Enrolling..." : enrollLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -5,14 +5,16 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useFacilities, useCreateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
import { Search, Plus, Trash2, Building } from "lucide-react";
import { useFacilities, useCreateFacility, useUpdateFacility, useDeleteFacility, useAssets, useCreateAsset, useDeleteAsset } from "@/hooks/queries";
import { Search, Plus, Trash2, Edit, Building } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function AdminFacilities() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"facilities" | "assets">("facilities");
const [facOpen, setFacOpen] = useState(false);
const [facEditOpen, setFacEditOpen] = useState(false);
const [facEditId, setFacEditId] = useState<number | null>(null);
const [assetOpen, setAssetOpen] = useState(false);
const [facForm, setFacForm] = useState({ name: "", code: "" });
const [assetForm, setAssetForm] = useState({ name: "", code: "" });
@@ -21,6 +23,7 @@ export default function AdminFacilities() {
const facQ = useFacilities();
const assetQ = useAssets();
const createFac = useCreateFacility();
const updateFac = useUpdateFacility();
const delFac = useDeleteFacility();
const createAsset = useCreateAsset();
const delAsset = useDeleteAsset();
@@ -104,21 +107,34 @@ export default function AdminFacilities() {
<TableCell className="font-medium">{f.name}</TableCell>
<TableCell>{f.code}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => {
if (!window.confirm("Delete this facility?")) return;
delFac.mutate(f.id, {
onSuccess: () => toast({ title: "Deleted" }),
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setFacEditId(f.id);
setFacForm({ name: f.name || "", code: f.code || "" });
setFacEditOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
onClick={() => {
if (!window.confirm("Delete this facility?")) return;
delFac.mutate(f.id, {
onSuccess: () => toast({ title: "Deleted" }),
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -179,13 +195,14 @@ export default function AdminFacilities() {
Cancel
</Button>
<Button
disabled={createFac.isPending}
disabled={createFac.isPending || !facForm.name.trim()}
onClick={() =>
createFac.mutate(
{ name: facForm.name, code: facForm.code || undefined },
{ name: facForm.name.trim(), code: facForm.code || undefined },
{
onSuccess: () => {
setFacOpen(false);
setFacForm({ name: "", code: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
@@ -200,6 +217,50 @@ export default function AdminFacilities() {
</DialogContent>
</Dialog>
<Dialog open={facEditOpen} onOpenChange={setFacEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit facility</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={facForm.name} onChange={(e) => setFacForm((f) => ({ ...f, name: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input value={facForm.code} onChange={(e) => setFacForm((f) => ({ ...f, code: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFacEditOpen(false)}>
Cancel
</Button>
<Button
disabled={updateFac.isPending || !facForm.name.trim() || !facEditId}
onClick={() => {
if (!facEditId) return;
updateFac.mutate(
{ id: facEditId, data: { name: facForm.name.trim(), code: facForm.code || undefined } },
{
onSuccess: () => {
setFacEditOpen(false);
setFacEditId(null);
setFacForm({ name: "", code: "" });
toast({ title: "Updated successfully" });
},
onError: (err: Error) =>
toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={assetOpen} onOpenChange={setAssetOpen}>
<DialogContent>
<DialogHeader>
@@ -226,13 +287,14 @@ export default function AdminFacilities() {
Cancel
</Button>
<Button
disabled={createAsset.isPending}
disabled={createAsset.isPending || !assetForm.name.trim()}
onClick={() =>
createAsset.mutate(
{ name: assetForm.name, code: assetForm.code || undefined },
{ name: assetForm.name.trim(), code: assetForm.code || undefined },
{
onSuccess: () => {
setAssetOpen(false);
setAssetForm({ name: "", code: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>

View File

@@ -1,40 +1,102 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useFeesPlans, useStudentFees } from "@/hooks/queries";
import { Label } from "@/components/ui/label";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { useFeesPlans, useStudentFees, useFeesPlan } from "@/hooks/queries";
import { feesService } from "@/services/fees.service";
import { useToast } from "@/hooks/use-toast";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useFeesPlan } from "@/hooks/queries";
import { Search, DollarSign, CreditCard, Eye } from "lucide-react";
import { Search, DollarSign, CreditCard, Eye, FileText, Wallet, Loader2 } from "lucide-react";
import type { FeesPlan } from "@/types/fees";
function planStateBadge(state: string) {
const s = state?.toLowerCase();
if (s === "draft") return <Badge variant="secondary">{state}</Badge>;
if (s === "ongoing") return <Badge variant="default">{state}</Badge>;
if (s === "done")
return (
<Badge variant="outline" className="border-green-600 text-green-700">
{state}
</Badge>
);
return <Badge variant="outline">{state}</Badge>;
function stateBadge(state: string) {
const s = (state || "").toLowerCase();
if (s === "draft") return <Badge variant="secondary">Draft</Badge>;
if (s === "invoice")
return <Badge variant="outline" className="border-blue-500 text-blue-600">Invoiced</Badge>;
if (s === "cancel") return <Badge variant="destructive">Cancelled</Badge>;
return <Badge variant="outline" className="capitalize">{state || "—"}</Badge>;
}
function paymentBadge(state?: string) {
const s = (state || "").toLowerCase();
if (s === "paid")
return <Badge className="bg-emerald-500 hover:bg-emerald-500/90 text-white">Paid</Badge>;
if (s === "in_payment")
return <Badge className="bg-amber-500 hover:bg-amber-500/90 text-white">In payment</Badge>;
if (s === "partial")
return <Badge className="bg-sky-500 hover:bg-sky-500/90 text-white">Partial</Badge>;
if (s === "reversed") return <Badge variant="destructive">Reversed</Badge>;
if (s === "not_paid")
return <Badge variant="outline" className="border-rose-500 text-rose-600">Not paid</Badge>;
return <Badge variant="outline" className="capitalize">{state || "—"}</Badge>;
}
function money(n: number | undefined, currency?: string) {
const v = Number(n ?? 0);
return `${v.toFixed(2)}${currency ? ` ${currency}` : ""}`;
}
export default function AdminFees() {
const [search, setSearch] = useState("");
const [section, setSection] = useState<"plans" | "student">("plans");
const [detailId, setDetailId] = useState<number | null>(null);
const [paymentFor, setPaymentFor] = useState<FeesPlan | null>(null);
const [paymentAmount, setPaymentAmount] = useState("");
const [paymentMemo, setPaymentMemo] = useState("");
const { toast } = useToast();
const plansQ = useFeesPlans();
const feesQ = useStudentFees();
const qc = useQueryClient();
const plansQ = useFeesPlans({ q: search || undefined, size: 200 });
const feesQ = useStudentFees({ q: search || undefined, size: 200 });
const detailQ = useFeesPlan(detailId ?? 0);
const plans = (plansQ.data?.data ?? plansQ.data?.items ?? []) as FeesPlan[];
const fees = (feesQ.data?.data ?? feesQ.data?.items ?? []) as FeesPlan[];
const loading = section === "plans" ? plansQ.isLoading : feesQ.isLoading;
const plans = plansQ.data?.data ?? plansQ.data?.items ?? [];
const fees = feesQ.data?.data ?? feesQ.data?.items ?? [];
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ["fees-plans"] });
qc.invalidateQueries({ queryKey: ["student-fees"] });
if (detailId) qc.invalidateQueries({ queryKey: ["fees-plan", detailId] });
};
const invoiceMut = useMutation({
mutationFn: (id: number) => feesService.createInvoice(id),
onSuccess: () => {
toast({ title: "Invoice created", description: "Accounting entry has been generated." });
invalidateAll();
},
onError: (e: Error) => toast({ title: "Invoice failed", description: e.message, variant: "destructive" }),
});
const paymentMut = useMutation({
mutationFn: async () => {
if (!paymentFor) throw new Error("No plan selected");
const amt = Number(paymentAmount);
return feesService.registerPayment(paymentFor.id, {
amount: Number.isFinite(amt) && amt > 0 ? amt : undefined,
memo: paymentMemo || undefined,
});
},
onSuccess: () => {
toast({ title: "Payment registered", description: "The invoice balance has been updated." });
setPaymentFor(null);
setPaymentAmount("");
setPaymentMemo("");
invalidateAll();
},
onError: (e: Error) => toast({ title: "Payment failed", description: e.message, variant: "destructive" }),
});
if (loading) {
return (
@@ -45,18 +107,27 @@ export default function AdminFees() {
}
const q = search.toLowerCase();
const filteredPlans = plans.filter(
const rows = section === "plans" ? plans : fees;
const filtered = rows.filter(
(p) =>
p.student_name?.toLowerCase().includes(q) || p.course_name?.toLowerCase().includes(q),
(p.student_name || "").toLowerCase().includes(q) ||
(p.course_name || "").toLowerCase().includes(q) ||
(p.product_name || "").toLowerCase().includes(q) ||
(p.invoice_number || "").toLowerCase().includes(q),
);
const filteredFees = fees.filter((f) => f.student_name?.toLowerCase().includes(q));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Fees</h1>
<p className="text-muted-foreground">Fee plans and student fee lines.</p>
<h1 className="text-2xl font-bold flex items-center gap-2">
<DollarSign className="h-7 w-7" />
Fees &amp; Payments
</h1>
<p className="text-muted-foreground">
Fee plans, linked invoices and payment status. Paid and remaining balances are pulled live from accounting.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant={section === "plans" ? "default" : "outline"} onClick={() => setSection("plans")}>
<DollarSign className="mr-2 h-4 w-4" />
@@ -67,101 +138,174 @@ export default function AdminFees() {
Student fees
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={section === "plans" ? "Search plans..." : "Search student fees..."}
placeholder="Search students, courses, invoice numbers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{section === "plans" ? (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Item / Course</TableHead>
<TableHead>Invoice</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Remaining</TableHead>
<TableHead>Plan state</TableHead>
<TableHead>Payment</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Total</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Remaining</TableHead>
<TableHead>State</TableHead>
<TableHead>Actions</TableHead>
<TableCell colSpan={10} className="text-center text-muted-foreground py-10">
No fees records match your search.
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlans.map((p, i) => (
)}
{filtered.map((p, i) => {
const hasInvoice = !!p.invoice_id;
const isPaid = (p.payment_state || "").toLowerCase() === "paid";
const canInvoice = !hasInvoice && p.state !== "cancel";
const canPay = hasInvoice && !isPaid;
return (
<TableRow key={p.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{p.student_name}</TableCell>
<TableCell>{p.course_name}</TableCell>
<TableCell>{p.total_amount}</TableCell>
<TableCell>{p.paid_amount}</TableCell>
<TableCell>{p.remaining_amount}</TableCell>
<TableCell>{planStateBadge(p.state)}</TableCell>
<TableCell>
<Button size="sm" variant="ghost" onClick={() => setDetailId(p.id)}>
<TableCell className="font-medium">{p.student_name || "—"}</TableCell>
<TableCell className="text-muted-foreground">
{p.product_name || p.course_name || "—"}
</TableCell>
<TableCell className="text-xs">
{p.invoice_number ? p.invoice_number : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-right tabular-nums">{money(p.total_amount, p.currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(p.paid_amount, p.currency)}</TableCell>
<TableCell className="text-right tabular-nums">{money(p.remaining_amount, p.currency)}</TableCell>
<TableCell>{stateBadge(p.state)}</TableCell>
<TableCell>{paymentBadge(p.payment_state)}</TableCell>
<TableCell className="text-right space-x-1">
<Button size="sm" variant="ghost" onClick={() => setDetailId(p.id)} title="View">
<Eye className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
disabled={!canInvoice || invoiceMut.isPending}
onClick={() => invoiceMut.mutate(p.id)}
title="Create invoice"
>
{invoiceMut.isPending && invoiceMut.variables === p.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="default"
disabled={!canPay}
onClick={() => {
setPaymentFor(p);
setPaymentAmount(String(p.remaining_amount ?? ""));
setPaymentMemo(`Payment for ${p.product_name || p.course_name || "fees"}`);
}}
title="Register payment"
>
<Wallet className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Student</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Date</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFees.map((f, i) => (
<TableRow key={f.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{f.student_name}</TableCell>
<TableCell>{f.amount}</TableCell>
<TableCell>{f.date}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{f.state}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={!!detailId} onOpenChange={(v) => { if (!v) setDetailId(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Fee Plan Details</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Fee Plan Details</DialogTitle>
<DialogDescription>Accounting-backed balance for this fee line.</DialogDescription>
</DialogHeader>
{detailQ.isLoading ? (
<div className="flex justify-center py-4"><div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" /></div>
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : detailQ.data ? (
<div className="space-y-2 text-sm">
<p><span className="font-medium">Student:</span> {detailQ.data.student_name}</p>
<p><span className="font-medium">Course:</span> {detailQ.data.course_name}</p>
<p><span className="font-medium">Total:</span> {detailQ.data.total_amount}</p>
<p><span className="font-medium">Paid:</span> {detailQ.data.paid_amount}</p>
<p><span className="font-medium">Remaining:</span> {detailQ.data.remaining_amount}</p>
<p><span className="font-medium">State:</span> {planStateBadge(detailQ.data.state)}</p>
<p><span className="font-medium">Student:</span> {detailQ.data.student_name || "—"}</p>
<p><span className="font-medium">Item / Course:</span> {detailQ.data.product_name || detailQ.data.course_name || "—"}</p>
<p><span className="font-medium">Invoice:</span> {detailQ.data.invoice_number || "Not created"}</p>
<p><span className="font-medium">Total:</span> {money(detailQ.data.total_amount, detailQ.data.currency)}</p>
<p><span className="font-medium">Paid:</span> {money(detailQ.data.paid_amount, detailQ.data.currency)}</p>
<p><span className="font-medium">Remaining:</span> {money(detailQ.data.remaining_amount, detailQ.data.currency)}</p>
<p className="flex items-center gap-2">
<span className="font-medium">State:</span> {stateBadge(detailQ.data.state)}
<span>·</span>
{paymentBadge(detailQ.data.payment_state)}
</p>
</div>
) : null}
</DialogContent>
</Dialog>
<Dialog open={!!paymentFor} onOpenChange={(v) => { if (!v) { setPaymentFor(null); setPaymentAmount(""); setPaymentMemo(""); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Register payment</DialogTitle>
<DialogDescription>
{paymentFor
? `Invoice ${paymentFor.invoice_number || ""} for ${paymentFor.student_name} · remaining ${money(paymentFor.remaining_amount, paymentFor.currency)}`
: ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Amount</Label>
<Input
type="number"
step="0.01"
value={paymentAmount}
onChange={(e) => setPaymentAmount(e.target.value)}
placeholder="Leave empty to settle the full remaining balance"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Memo</Label>
<Input
value={paymentMemo}
onChange={(e) => setPaymentMemo(e.target.value)}
placeholder="Payment communication"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPaymentFor(null)}>Cancel</Button>
<Button
disabled={paymentMut.isPending}
onClick={() => paymentMut.mutate()}
>
{paymentMut.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" />Registering...</>
) : (
"Register payment"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useGradebooks, useGradingAssignments, useCreateGradingAssignment, useUpdateGradingAssignment, useDeleteGradingAssignment, useCourses, useSubjects } from "@/hooks/queries";
import { useGradebooks, useGradebookLines, useGradingAssignments, useCreateGradingAssignment, useUpdateGradingAssignment, useDeleteGradingAssignment, useCourses, useSubjects } from "@/hooks/queries";
import { Search, Plus, BookOpen, Pencil, Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
@@ -17,6 +17,7 @@ export default function AdminGradebook() {
const [createOpen, setCreateOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [form, setForm] = useState({ name: "", course_id: "", subject_id: "", issued_date: "" });
const [drillId, setDrillId] = useState<number | null>(null);
const { toast } = useToast();
const booksQ = useGradebooks();
const assignQ = useGradingAssignments();
@@ -30,6 +31,9 @@ export default function AdminGradebook() {
const loading = section === "books" ? booksQ.isLoading : assignQ.isLoading;
const books = booksQ.data?.data ?? booksQ.data?.items ?? [];
const assignments = assignQ.data?.data ?? assignQ.data?.items ?? [];
const drillQ = useGradebookLines(drillId ? { gradebook_id: drillId, size: 200 } : undefined);
const drillLines = drillQ.data?.data ?? drillQ.data?.items ?? [];
const drillBook = drillId ? books.find((b) => b.id === drillId) : null;
if (loading) {
return (
@@ -100,7 +104,7 @@ export default function AdminGradebook() {
</TableHeader>
<TableBody>
{filteredBooks.map((b, i) => (
<TableRow key={b.id}>
<TableRow key={b.id} className="cursor-pointer hover:bg-accent/40" onClick={() => setDrillId(b.id)}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{b.student_name}</TableCell>
<TableCell>{b.course_name}</TableCell>
@@ -157,6 +161,49 @@ export default function AdminGradebook() {
</Card>
)}
<Dialog open={!!drillId} onOpenChange={(v) => { if (!v) setDrillId(null); }}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
Gradebook{drillBook ? `${drillBook.student_name} · ${drillBook.course_name}` : ""}
</DialogTitle>
</DialogHeader>
{drillQ.isLoading ? (
<div className="flex items-center justify-center py-10">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : drillLines.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">No gradebook lines recorded yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Assignment</TableHead>
<TableHead>Marks</TableHead>
<TableHead>%</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{drillLines.map((l, i) => (
<TableRow key={l.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{l.assignment_name || "—"}</TableCell>
<TableCell>{typeof l.marks === "number" ? l.marks : "—"}</TableCell>
<TableCell>{typeof l.percentage === "number" ? l.percentage.toFixed(1) : "—"}</TableCell>
<TableCell><Badge variant="outline" className="capitalize">{l.state || "draft"}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDrillId(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) setEditId(null); }}>
<DialogContent>
<DialogHeader>

View File

@@ -54,9 +54,9 @@ export default function AdminLessons() {
const openEdit = (l: Lesson) => {
setEditing(l);
setForm({
lesson_topic: l.name,
course_id: "",
batch_id: "",
lesson_topic: l.lesson_topic || l.name,
course_id: l.course_id ? String(l.course_id) : "",
batch_id: l.batch_id ? String(l.batch_id) : "",
subject_id: String(l.subject_id ?? ""),
});
setEditOpen(true);
@@ -220,6 +220,26 @@ export default function AdminLessons() {
<Label>Lesson Topic</Label>
<Input value={form.lesson_topic} onChange={(e) => setForm((f) => ({ ...f, lesson_topic: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v === "__none__" ? "" : v, batch_id: "" }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{courses.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Batch</Label>
<Select value={form.batch_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, batch_id: v === "__none__" ? "" : v }))} disabled={!form.course_id}>
<SelectTrigger><SelectValue placeholder={form.course_id ? "Select batch" : "Pick course first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{batchesForCourse.map((b) => <SelectItem key={b.id} value={String(b.id)}>{b.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Select value={form.subject_id || "__none__"} onValueChange={(v) => setForm((f) => ({ ...f, subject_id: v === "__none__" ? "" : v }))}>
@@ -245,6 +265,8 @@ export default function AdminLessons() {
data: {
lesson_topic: form.lesson_topic,
name: form.lesson_topic,
course_id: form.course_id ? Number(form.course_id) : undefined,
batch_id: form.batch_id ? Number(form.batch_id) : undefined,
subject_id: form.subject_id ? Number(form.subject_id) : undefined,
},
},

View File

@@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { useLibraryMedia, useCreateMedia, useDeleteMedia, useLibraryMovements, useCreateMovement, useReturnMovement, useLibraryCards, useCreateCard } from "@/hooks/queries";
import { useLibraryMedia, useCreateMedia, useDeleteMedia, useLibraryMovements, useCreateMovement, useReturnMovement, useLibraryCards, useCreateCard, useStudents } from "@/hooks/queries";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Plus, Book, ArrowUpDown, Trash2, RotateCcw } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
@@ -31,6 +32,8 @@ export default function AdminLibrary() {
const createMoveMut = useCreateMovement();
const returnMoveMut = useReturnMovement();
const createCardMut = useCreateCard();
const studentsQ = useStudents({ size: 500 });
const students = studentsQ.data?.items ?? [];
const loading =
tab === "media" ? mediaQ.isLoading : tab === "movements" ? movQ.isLoading : cardsQ.isLoading;
@@ -258,11 +261,11 @@ export default function AdminLibrary() {
Cancel
</Button>
<Button
disabled={createMediaMut.isPending}
disabled={createMediaMut.isPending || !mediaForm.name.trim()}
onClick={() =>
createMediaMut.mutate(
{
name: mediaForm.name,
name: mediaForm.name.trim(),
isbn: mediaForm.isbn || undefined,
author: mediaForm.author || undefined,
edition: mediaForm.edition || undefined,
@@ -270,6 +273,7 @@ export default function AdminLibrary() {
{
onSuccess: () => {
setMediaOpen(false);
setMediaForm({ name: "", isbn: "", author: "", edition: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>
@@ -291,17 +295,38 @@ export default function AdminLibrary() {
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Media ID</Label>
<Input type="number" value={moveForm.media_id} onChange={(e) => setMoveForm((f) => ({ ...f, media_id: e.target.value }))} />
<Label>Media</Label>
<Select value={moveForm.media_id || "__none__"} onValueChange={(v) => setMoveForm((f) => ({ ...f, media_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select media" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{media.map((m) => (<SelectItem key={m.id} value={String(m.id)}>{m.name}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Student ID</Label>
<Input type="number" value={moveForm.student_id} onChange={(e) => setMoveForm((f) => ({ ...f, student_id: e.target.value }))} />
<Label>Student</Label>
<Select value={moveForm.student_id || "__none__"} onValueChange={(v) => setMoveForm((f) => ({ ...f, student_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="Select student" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{students.map((s) => (<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setMoveOpen(false)}>Cancel</Button>
<Button disabled={createMoveMut.isPending} onClick={() => createMoveMut.mutate({ media_id: Number(moveForm.media_id), student_id: Number(moveForm.student_id) }, { onSuccess: () => { setMoveOpen(false); toast({ title: "Issued" }); }, onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) })}>
<Button
disabled={createMoveMut.isPending || !moveForm.media_id || !moveForm.student_id}
onClick={() => createMoveMut.mutate(
{ media_id: Number(moveForm.media_id), student_id: Number(moveForm.student_id) },
{
onSuccess: () => { setMoveOpen(false); setMoveForm({ media_id: "", student_id: "" }); toast({ title: "Issued" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
)}
>
Issue
</Button>
</DialogFooter>
@@ -312,8 +337,14 @@ export default function AdminLibrary() {
<DialogContent>
<DialogHeader><DialogTitle>Create library card</DialogTitle></DialogHeader>
<div className="space-y-2">
<Label>Student ID</Label>
<Input type="number" value={cardStudentId} onChange={(e) => setCardStudentId(e.target.value)} />
<Label>Student</Label>
<Select value={cardStudentId || "__none__"} onValueChange={(v) => setCardStudentId(v === "__none__" ? "" : v)}>
<SelectTrigger><SelectValue placeholder="Select student" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{students.map((s) => (<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCardOpen(false)}>Cancel</Button>

View File

@@ -234,6 +234,7 @@ export default function AdminStudentLeave() {
{
onSuccess: () => {
setCreateOpen(false);
setForm({ student_id: "", leave_type: "", start_date: "", end_date: "", description: "" });
toast({ title: "Created successfully" });
},
onError: (err: Error) =>

Some files were not shown because too many files have changed in this diff Show More